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)— reconstructsTableStatefrom an Angular route’sparamMap+queryParamMap. Honours per-columnsourcesettings. -
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
Same as numeric, ISO-8601 instants |
|
|
|
|
|
|
|
sort |
repeated |
|
pagination |
|
|
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 |
|---|---|
|
Read filter value from |
|
Read filter value from |
|
Try path first, then query. Lets the same column work whether the screen is hit at |
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 rendered. |
|
Never rendered. Use for internal-only fields like |
|
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 |
|
User can toggle the column on or off via a column-chooser dropdown. A |
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 |
|---|---|
|
Plain |
|
Bootstrap checkbox group; multi-select; emits |
|
Two number inputs (min, max); emits |
|
Two |
|
Numeric ID input today; planned upgrade to |
|
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’sPaginationState+SortingState+ColumnFiltersState→ ourTableState. -
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:
-
Confirm the back-end criterion exists.
FooCriteriashould expose the fields you want to filter on, with the appropriate*Filtertypes (StringFilter,LongFilter,InstantFilter, etc.). If not, generate or extend it. -
Define the column config. Create
src/app/shared/foo/foo-inventory.config.tswithColumnConfig<IFoo>[]. One entry per column — including non-filterable, non-sortable display-only columns. UseapiFieldfor FK overrides,sort.aliasfor joined-field sort,visible: 'never'for IDs. -
Implement the list component. Create
TanstackFooListComponent, wiring the Tanstack table to:-
fromRoute(pathParams, queryParams, columns)for initial state. -
toApiQueryParams(state, columns)for the service call. -
toUrlQueryParams(state, columns, pathParams)for navigation back to the URL. -
Hosting
<ems-active-filter-chips>above the table. -
Hosting per-column
<ngb-dropdown>popovers with the matching widget percol.filter.kind.
-
-
Wire the route(s).
-
Tenant-wide:
/foo-inventory. -
Path-pinned (if applicable):
/event/:eventId/foos, with the eventId-bound column declaredsource: 'path'in the config. -
Query-pinned (if applicable): no extra route; just append
?<apiField>.equals=<id>to a tenant-wide link.
-
-
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 ( |
Back-end exposes them as plain boolean query params ( |
|
Default-off shows only |
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:
-
Open the panel by navigating to add
?view=<rowId>to the URL —queryParamsHandling: 'merge'so other params survive. -
Close the panel by navigating to set
?view=null— browser back-button closes the panel naturally because URL transitions are history entries. -
The panel reads its target id from a
viewedEpIdcomputed signal; the panel itself is purely presentational (input: row, output: close). -
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 inrows(), clear it whenever the viewed id changes. The panel reads the latest visible row first, falls back to the snapshot, returns null only whenviewedEpIdis 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 |
Set semantics — order doesn’t matter, lookup is O(1). |
Selection is cleared on every scope change |
The row-fetch effect calls |
Bulk toolbar surfaces at ≥2 selected (per consumer’s policy) |
The |
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 |
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.
-
Value-equal derived state.
stateandchipFlagsusecomputed(…, { equal: stateEqual }).fromRoutereturns a fresh object reference on every router emit; without value-equality, the fetch effect re-fires in a loop on every navigation. -
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. -
onCleanupfor in-flight HttpClient cancellation. The effect’sonCleanuphook 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). -
Stable
getCoreRowModel()instance. Hoist to a class field —private readonly coreRowModel = getCoreRowModel(). Calling it inside thecreateAngularTableoptions factory creates a new factory on every options re-eval and breaks Tanstack’s row-model memoisation. -
Snapshot signals for survive-the-fetch UI (side panel, modal). See Side Panel above. Pattern: a
*Snapshotsignal mirroring a route-param-driven*Idsignal, refreshed fromrows()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 writesnullkeys to the URL as removed. Always emitnullfor 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
viewis 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
-
$localizepolyfill is mandatory.ngb-paginationuses$localizefor ARIA labels and crashes the template render if the polyfill is missing. Surface aspolyfills: ["@angular/localize/init"]inangular.json. -
Tanstack reactivity gotchas. The route-derived
statesignal must use value equality (deep-equal comparator) — otherwisefromRoute’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 thecreateAngularTablefactory. EachColumnDefneeds anaccessorFn. 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
-
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). -
User-selectable columns. Add
visible: 'user-controlled'mode +<ems-column-chooser>dropdown + pluggableColumnVisibilityStore(localStorage default; server-side per-user prefs as a follow-up). Lets users hide rarely-used columns by default and opt in via UI. -
Saved filter presets. "Save this combination of filters as 'My Open Issues'." Per-user, server-side. Out of scope for the initial pattern.
-
CSV export. Add an "Export" button that re-issues the current query with a server-side
Accept: text/csvand 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
-
Filterable List Pattern — Substrate Choice — the why and the alternatives.
-
Design Journal — full evaluation history, prototype scope, day-by-day log, and bundle / LOC measurements.
-
OpenAPI Contract Flow — how
<Entity>Criteriashapes are exposed to the front-end. -
JHipster — Entities Filtering — authoritative reference for the back-end URL shape this pattern targets.