Filterable List Pattern — Design

1. Overview

A list screen in admin-portal is a paginated, sortable table over a JHipster Specification-filtered REST endpoint, with column-level filters, multi-column server-side sort, and a route that may pin some filters via path or query parameters. This pattern provides a small library of reusable Angular code that turns a single declarative column configuration per entity into a complete list screen, with the back-end glue, the URL round-trip, and the column-filter UX handled by shared components.

The substrate decision (Tanstack @tanstack/angular-table + Bootstrap 5 + ng-bootstrap) and the alternatives evaluated are recorded in Filterable List Pattern — Substrate Choice. This page focuses on the pattern — the abstractions, their contract with the back-end, and how to use them.

2. Core Abstraction: Column-as-Parent

The pattern’s central type is ColumnConfig<T>one entry per column, with filtering, sorting, and visibility expressed as facets of that column rather than as separate parallel lists. A list screen is a single ColumnConfig<TEntity>[] array fed to a generic table component.

interface ColumnConfig<T> {
  field: string;          // logical column key (dotted paths allowed for display: 'subType.name')
  apiField?: string;      // JHipster criterion name override (e.g. 'subTypeId' for an FK column)
  label: string;          // header text
  visible?: 'always' | 'never' | 'hide-when-pinned';
  filter?: FilterFacet;   // omit to make column non-filterable
  sort?: { alias?: string };   // omit to make column non-sortable; alias for joined-field sorts
  source?: 'query' | 'path' | 'auto';   // where pinned filter values come from
  formatter?: (row: T) => string;
}

FilterFacet is a discriminated union — one shape per filter kind:

type FilterFacet =
  | { kind: 'text';      op: 'contains' | 'equals' }
  | { kind: 'enum';      op: 'in' }
  | { kind: 'numeric';   op: 'range' }   // emits gte + lte
  | { kind: 'date';      op: 'range' }   // emits gte + lte over instants
  | { kind: 'fk';        op: 'equals' }
  | { kind: 'specified' };               // emits .specified=true|false

Adding a new column to a list screen is one entry in this array. Adding a new filter shape across all list screens is one new variant in the discriminated union plus one new widget — done once, available everywhere.

3. Architecture

ActivatedRoute (path + query params)
    │
    ▼
fromRoute() ─────────► TableState (first, rows, sort[], filters{})
                            │                             │
                            ▼                             ▼
                  toApiQueryParams()              toUrlQueryParams()
                            │                             │
                            ▼                             ▼
                  GET /api/<entity>            router.navigate() → URL
                  + X-Total-Count
                            │
                            ▼
                  rows + totalRecords signals
                            │
                            ▼
                   <p-table> / Tanstack table renders

The pattern’s central data type is TableState{ first, rows, sort: SortDescriptor[], filters: FilterValueMap }. Two pure functions move it in and out:

  • fromRoute(pathParams, queryParams, columns) — reconstructs TableState from an Angular route’s paramMap + queryParamMap. Honours per-column source settings.

  • toApiQueryParams(state, columns) — emits the JHipster Specification API parameters for the back-end call (every active filter, regardless of source).

  • toUrlQueryParams(state, columns, pathParams?) — emits the router URL queryParams (excludes columns whose values are pinned to the path, so we don’t duplicate them).

These three functions are the contract. They are pure and have no Angular dependency beyond the small ParamReader interface they consume — easily unit-testable, easily reusable.

4. JHipster URL Contract

The pattern emits and consumes the standard JHipster Specification URL shape verbatim — no compressed encoding, no custom prefixes. Each table-state entry maps to one URL parameter:

Filter facet URL parameter Example

text { op: 'contains' }

<field>.contains=<value>

?number.contains=12

text { op: 'equals' }

<field>.equals=<value>

?colour.equals=Red

enum { op: 'in' }

<field>.in=<v1,v2,v3>

?state.in=S,I,U

numeric { op: 'range' }

<field>.greaterThanOrEqual=<min> and/or <field>.lessThanOrEqual=<max>

?sequence.greaterThanOrEqual=100&sequence.lessThanOrEqual=200

date { op: 'range' }

Same as numeric, ISO-8601 instants

?lastUsed.greaterThanOrEqual=2026-01-01T00:00:00Z

fk { op: 'equals' }

<apiField>.equals=<id>

?subTypeId.equals=7

specified

<apiField>.specified=true | false

?personId.specified=true

sort

repeated sort=<field>,<asc|desc>

?sort=state,asc&sort=number,asc

pagination

page=<n>&size=<m>

?page=2&size=20

This is the same URL the back-end’s <Entity>Criteria class deserialises directly — no per-entity translation required.

5. Path-vs-Query Sourcing

Some list screens are accessed in the context of a parent (e.g. /event/:eventId/race-numbers) — the filter on eventId is sourced from the URL path, not the query string. ColumnConfig.source controls this:

source Behaviour

'query' (default)

Read filter value from queryParamMap.get('<apiField>.equals') etc. User-set filters; freely toggleable.

'path'

Read filter value from paramMap.get('<apiField>'). Pinned by the route; renders as a locked chip in the chip strip.

'auto'

Try path first, then query. Lets the same column work whether the screen is hit at /event/:eventId/race-numbers or /race-number-inventory?subTypeId.equals=7.

toUrlQueryParams() excludes path-sourced columns when writing the URL — so changing a query filter on /event/42/race-numbers produces /event/42/race-numbers?state.in=S, not /event/42/race-numbers?eventId.equals=42&state.in=S.

6. Visibility

ColumnConfig.visible controls whether a column renders. Three values today, with one extension on the roadmap:

visible Behaviour

'always' (default)

Always rendered.

'never'

Never rendered. Use for internal-only fields like id.

'hide-when-pinned'

Rendered when no pinned value is in scope; hidden when the column’s filter is pinned by the route. E.g. the Event column hides on /event/42/race-numbers because every row has the same value.

'user-controlled' (planned, see roadmap)

User can toggle the column on or off via a column-chooser dropdown. A defaultShown?: boolean field decides the initial state. User selection persists per-screen via a pluggable ColumnVisibilityStore (localStorage default; server-side per-user prefs as a future enhancement).

The visibility resolution is centralised in one helper so a list screen never has to think about the rules.

7. Active-Filter Chip Strip

A library-agnostic <ems-active-filter-chips> component renders one chip per active filter, with:

  • The column label and the value.

  • A close button if the filter is removable (i.e. not path-pinned).

  • A lock icon if the filter is pinned by the route.

  • A "Clear all" button that strips removable filters in one action.

The chip strip reads TableState.filters and column config; it has no opinion about the table substrate.

8. Per-Facet Filter Widgets

Six standalone Angular components, one per filter facet. Each takes a value: FilterValue | undefined input and emits a valueChange output:

Widget Notes

<ems-text-filter>

Plain <input type="text">; op switches between contains/equals.

<ems-enum-filter>

Bootstrap checkbox group; multi-select; emits { kind: 'enum', values: string[] }.

<ems-numeric-filter>

Two number inputs (min, max); emits { kind: 'numeric', min?, max? }.

<ems-date-filter>

Two <input type="date"> inputs; emits ISO instants.

<ems-fk-filter>

Numeric ID input today; planned upgrade to <ngb-typeahead> once a real FK lookup endpoint is wired in (e.g. Person search).

<ems-specified-filter>

Tri-state radio group (any / set / not set).

Widgets are reusable inside any popover host. The Tanstack list uses <ngb-dropdown> per column header; a future PrimeNG list (if revived) would host them inside <p-columnFilter pTemplate="filter">.

9. Substrate Adapter

A small state-translation adapter per substrate translates the substrate’s native state shape to and from TableState:

  • tableStateFromTanstack(pagination, sorting, columnFilters, columns) — Tanstack’s PaginationState + SortingState + ColumnFiltersState → our TableState.

  • paginationFromTableState(state), tanstackSortingFromTableState(state, columns), tanstackFiltersFromTableState(state) — the inverse, used to seed the table from URL state.

  • The Tanstack column ID convention is column.field (so filter keys round-trip cleanly); sort fields are translated to/from the JHipster alias (column.sort.alias) at the adapter boundary.

The adapter is the only library-specific code in the pattern. Swapping substrates means writing a new adapter (~100 LOC) and a new table component (~500 LOC); the shared layer is unchanged.

10. Recipe: Add a New List Screen

To add a new list screen (Foo), assuming the pattern is already in place:

  1. Confirm the back-end criterion exists. FooCriteria should expose the fields you want to filter on, with the appropriate *Filter types (StringFilter, LongFilter, InstantFilter, etc.). If not, generate or extend it.

  2. Define the column config. Create src/app/shared/foo/foo-inventory.config.ts with ColumnConfig<IFoo>[]. One entry per column — including non-filterable, non-sortable display-only columns. Use apiField for FK overrides, sort.alias for joined-field sort, visible: 'never' for IDs.

  3. Implement the list component. Create TanstackFooListComponent, wiring the Tanstack table to:

    1. fromRoute(pathParams, queryParams, columns) for initial state.

    2. toApiQueryParams(state, columns) for the service call.

    3. toUrlQueryParams(state, columns, pathParams) for navigation back to the URL.

    4. Hosting <ems-active-filter-chips> above the table.

    5. Hosting per-column <ngb-dropdown> popovers with the matching widget per col.filter.kind.

  4. Wire the route(s).

    1. Tenant-wide: /foo-inventory.

    2. Path-pinned (if applicable): /event/:eventId/foos, with the eventId-bound column declared source: 'path' in the config.

    3. Query-pinned (if applicable): no extra route; just append ?<apiField>.equals=<id> to a tenant-wide link.

  5. Test the round-trip. Click a column header to add a sort; check the URL gains ?sort=…​. Reload — sort persists. Set a filter; URL updates. Path-pinned filter renders as a locked chip; query-pinned as a removable chip.

A new column on an existing screen is one new entry in the column config; no other code changes.

11. List Screen Composition

The substrate above (column config, query adapter, widgets, chip strip) is necessary but not sufficient for a real list screen. The first canonical adopter — the Event Participants list at [tanstack-event-participant-list.component.ts](https://github.com/christhonie/event-admin-portal/blob/main/src/main/webapp/app/event-participants/tanstack-event-participant-list.component.ts) — surfaces six composition patterns that recur across list screens. Build a new screen by composing these on top of the substrate; reach for the EP component as the canonical bake whenever the prose here leaves a question unanswered.

The visual chrome that hosts these patterns (page header, stats strip, filter chips row, utilities row) is specified separately in List Screen Affordances; the table itself is C07 Data Table. This section covers the behavioural composition — what wires to what, and where state lives.

11.1. Out-of-Band Filter Dimensions

Some filters don’t fit JHipster’s Specification facet shape and can’t be expressed as a FilterFacet on a ColumnConfig. The EP screen has two:

Dimension Why out-of-band

Stats-strip chip flags (firstTimeOnly, returningOnly, unpaid, unassignedNumber)

Back-end exposes them as plain boolean query params (?unpaid=true), not as Specification facets (?<field>.specified=true). They are triggered by the stats strip, not by per-column popovers — first-class chip semantics, not column-driven filtering.

showInactive scope toggle

Default-off shows only active=true rows; on shows the full population. Lives top-bar, not per-column. The component owns the discipline of stripping any substrate-emitted active. params* before issuing the fetch — the toggle is the single source of truth for the active dimension; without the strip, a deep link or stale URL state could race the toggle and the back-end would receive contradictory criteria.

These dimensions live in their own signals alongside TableState, not inside it. The fetch effect merges them with toApiQueryParams(state, columns) before calling the service. The substrate’s TableState.filters map is left clean — keeping the substrate single-purpose makes the chip-strip and toggle semantics first-class, and prevents column-substrate widgets from accidentally surfacing controls for dimensions that don’t map to columns.

The EP model module (event-participant.model.ts) declares IEventParticipantChipFlags + CHIP_FLAG_KEYS next to the substrate types; the list component reads them from queryParamMap via a chipFlags computed signal with value-equality so the fetch effect doesn’t loop.

11.2. Side Panel via ?view=<id> + Snapshot Fallback

A row-detail side panel is a recurring need (E02 → E09, future T02 → T-detail, M02 → member-detail). The EP pattern:

  1. Open the panel by navigating to add ?view=<rowId> to the URL — queryParamsHandling: 'merge' so other params survive.

  2. Close the panel by navigating to set ?view=null — browser back-button closes the panel naturally because URL transitions are history entries.

  3. The panel reads its target id from a viewedEpId computed signal; the panel itself is purely presentational (input: row, output: close).

  4. Snapshot fallback: the panel must survive page / sort / filter changes that move the viewed row off the visible page. Track a viewedSnapshot: signal<TRow | null>; refresh it whenever the viewed row appears in rows(), clear it whenever the viewed id changes. The panel reads the latest visible row first, falls back to the snapshot, returns null only when viewedEpId is genuinely null.

The single navigate entry-point (see Single navigate() Entry-Point below) must explicitly forward view across substrate-driven navigations — otherwise sort / filter / page changes would clobber the panel’s URL state.

The same recipe scales to row-summoned modals — see the EP component’s clusterModalEpId + clusterModalSnapshot pair, plus a captured clusterReturnTarget element so focus returns to the originating chip on close.

11.3. Bulk-Selection Lifecycle

Multi-row actions (delete-selected, export-selected, etc.) need a selection set, a toolbar, and discipline around when selection is preserved.

Rule Reason

Selection is signal<ReadonlySet<rowId>>

Set semantics — order doesn’t matter, lookup is O(1).

Selection is cleared on every scope change

The row-fetch effect calls clearSelection() on every re-fire. Route reuse, browser back/forward, deep links, and navigate() all flow through this effect, so it’s the single source of truth. Without this, a user could think "3 selected" while only 1 row is visible after filtering.

Bulk toolbar surfaces at ≥2 selected (per consumer’s policy)

The bulkActive = computed(() ⇒ selectedIds().size >= 2) signal drives both the toolbar surface and the row-action-disable rule. Threshold is a screen-level decision; C07 models it as a caller-controlled bulkMode prop.

Row-level actions disable while bulk is active

Spec discipline (E02). Operator’s intent is unambiguous — they’re acting on the set, not a row.

Header checkbox uses [indeterminate] — derived from visible rows, not selectedIds().size

Defensive: if cross-page selection is ever enabled, the indeterminate flag still reflects the visible page correctly.

Bulk action handlers are screen-specific. The lifecycle is universal.

11.4. Path-Param Remap (routeParamReader)

Angular convention is to name route params :id (e.g. /events/:id/participants); the substrate’s path-source lookup keys off the column’s apiField (e.g. eventId). These don’t match, and they shouldn’t — the route shape is page-router-driven, the column shape is back-end-criteria-driven.

The seam is a small adapter at the bottom of the list component:

function routeParamReader(pathParams: ParamReader, eventId: string | null): ParamReader {
    return {
        get: key => (key === 'eventId' ? eventId : pathParams.get(key)),
        getAll: key => (key === 'eventId' ? (eventId ? [eventId] : []) : pathParams.getAll(key)),
    };
}

The component wraps its raw paramMap once in a pathReader computed signal; every consumer (fromRoute, toUrlQueryParams, the visibility helper, the chip-strip’s pinned-detection) sees the same column-config-shaped key. Adapt this for any pinned dimension — the only thing that changes per screen is the source param name and the destination apiField.

11.5. Effects Discipline

Five rules; all five are baked into the EP component. The Tanstack-substrate-specific items (1, 4) repeat the upstream caveats; the others are general signal-list hygiene.

  1. Value-equal derived state. state and chipFlags use computed(…​, { equal: stateEqual }). fromRoute returns a fresh object reference on every router emit; without value-equality, the fetch effect re-fires in a loop on every navigation.

  2. untracked() for synchronous signal writes inside effects. Angular’s NG0600 throws on signal writes from inside an effect body unless they’re explicitly opted out of the reactive graph — these writes are intentional side-effects of the effect’s reactive read, not new reactive sources. HttpClient subscribers don’t need wrapping (their callbacks fire after the effect body completes); RxJS observables that may emit synchronously do.

  3. onCleanup for in-flight HttpClient cancellation. The effect’s onCleanup hook fires before the next iteration AND on component destroy. One line cancels both stale fetches (no out-of-order writes overwriting newer state during rapid sort/page changes) and pending fetches on destroy (no leak).

  4. Stable getCoreRowModel() instance. Hoist to a class field — private readonly coreRowModel = getCoreRowModel(). Calling it inside the createAngularTable options factory creates a new factory on every options re-eval and breaks Tanstack’s row-model memoisation.

  5. Snapshot signals for survive-the-fetch UI (side panel, modal). See Side Panel above. Pattern: a *Snapshot signal mirroring a route-param-driven *Id signal, refreshed from rows() when the row appears, retained when it doesn’t.

11.6. Single navigate() Entry-Point + Foreign-Param Preservation

A list screen accumulates URL parameters from multiple channels — substrate filters, sort, pagination, chip flags, scope toggle, side-panel id. A naive multi-call-site approach drops parameters silently every time one channel navigates without knowing about the others (Angular’s Router.navigate rebuilds the query string from the supplied object; absent keys are removed).

Funnel every URL transition through one private method:

private navigate(state: TableState, chipFlags?: IEventParticipantChipFlags, showInactive?: boolean): void {
    // Fast no-op short-circuit
    if (stateEqual(state, this.state())
        && chipFlagsEqual(flags, this.chipFlags())
        && inactive === this.showInactive()) return;

    const queryParams = { ...toUrlQueryParams(state, this.columns, this.pathReader()) };
    for (const key of CHIP_FLAG_KEYS) queryParams[key] = flags[key] ? 'true' : null;
    queryParams['showInactive'] = inactive ? 'true' : null;

    // Foreign-param preservation: any `view=<id>` survives substrate-driven nav
    const currentView = this.maps().query.get('view');
    if (currentView !== null) queryParams['view'] = currentView;

    this.router.navigate([], { relativeTo: this.route, queryParams, replaceUrl: true });
}

Two non-obvious bits:

  • null-valued keys are explicit clears. Angular drops absent keys but writes null keys to the URL as removed. Always emit null for false flags so they round-trip cleanly.

  • replaceUrl: true. Single history entry per state change; back/forward isn’t littered with intermediate filter/sort/page hops.

  • Side-panel view is foreign and must be preserved. Substrate-driven navigations don’t know about it; this method does. Same applies to any other ambient query param a future channel adds.

The substrate cannot own this — it doesn’t know which params the screen treats as foreign. Every screen using a side panel or any out-of-band param needs its own navigate() with the appropriate preservation list.

12. Caveats

  • $localize polyfill is mandatory. ngb-pagination uses $localize for ARIA labels and crashes the template render if the polyfill is missing. Surface as polyfills: ["@angular/localize/init"] in angular.json.

  • Tanstack reactivity gotchas. The route-derived state signal must use value equality (deep-equal comparator) — otherwise fromRoute’s fresh-object output re-fires the fetch effect on every router emit. `getCoreRowModel() must be hoisted to a stable class field, not called inside the createAngularTable factory. Each ColumnDef needs an accessorFn. The shared upstream component bakes these in.

  • Date-range as one logical dimension. The pattern emits two URL params (gte + lte) but is one column in the config, with one widget that controls both bounds. Don’t split it into two columns.

  • Server-side filtering only. The back-end does the work. Client-side filtering on a static dataset is a different pattern (and probably a different table substrate).

13. Roadmap

  1. FK typeahead. Replace <ems-fk-filter>’s numeric-ID input with `<ngb-typeahead> once a real FK lookup is needed (likely Person search on EventParticipant).

  2. User-selectable columns. Add visible: 'user-controlled' mode + <ems-column-chooser> dropdown + pluggable ColumnVisibilityStore (localStorage default; server-side per-user prefs as a follow-up). Lets users hide rarely-used columns by default and opt in via UI.

  3. Saved filter presets. "Save this combination of filters as 'My Open Issues'." Per-user, server-side. Out of scope for the initial pattern.

  4. CSV export. Add an "Export" button that re-issues the current query with a server-side Accept: text/csv and streams the unpaginated result. Trivial extension; deferred until a screen needs it.

14. Status

The pattern is active and evolving. Upstream is ~/dev/ems/prototypes/filterable-list/, which serves as the design surface for refinements; admin-portal mirrors the shared layer with one-line upstream-pointer comments at the top of each file. Refinements driven by new list screens are designed in the prototype first (with unit tests), then mirrored downstream.

This document is the living reference once the prototype retires (after 1–2 admin-portal list-screen iterations stabilise the surface). Updates surfaced by future design-journal threads land here.

15. References