spiderly 19.11.8 → 19.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -190,7 +190,7 @@ Every paginated table offers a rows-per-page dropdown — `rowsPerPageOptions`,
190
190
  ```typescript
191
191
  cols: Column<ProductDTO>[] = [
192
192
  new Column({ field: 'title', name: 'Title', filterType: 'text', lockVisible: true }),
193
- new Column({ field: 'price', name: 'Price', filterType: 'numeric', showMatchModes: true, decimalPlaces: 2 }),
193
+ new Column({ field: 'price', name: 'Price', filterType: 'numeric', decimalPlaces: 2 }),
194
194
  new Column({ field: 'createdAt', name: 'Created', filterType: 'date', showTime: true, visible: false }),
195
195
  new Column({ field: 'isActive', name: 'Active', filterType: 'boolean' }),
196
196
  new Column({ field: 'categoryDisplayName', name: 'Category', filterType: 'multiselect',
@@ -207,11 +207,70 @@ cols: Column<ProductDTO>[] = [
207
207
 
208
208
  Headers are click-to-sort by default; disable with `sortable: false`. Columns whose `field` ends in `CommaSeparated` are auto-disabled — the backend generates no sort case for collection columns and rejects unknown sort fields with a 400, so the table never offers the click.
209
209
 
210
- Column widths default per `filterType` and are sized for the **header** — the filter input plus its match-mode dropdown — so a column of short values (an amount, a status code) reserves more than it uses. `minWidth: '8rem'` overrides that floor for one column; it stays a minimum, so the table still distributes the leftover space.
210
+ The table lays its columns out from their **declared widths, never from the rows on screen** (`table-layout: fixed`). The browser default would size every column from the content of the page currently rendered, so a column is one width on this page and another on the next, and a filter — which narrows the variety of values in the column it filters — shifts the whole grid under an operator who is scanning it.
211
+
212
+ Widths default per `filterType` (the column's value shape) and are generous, so a column of short values (an amount, a status code) may reserve more than it uses. `width: '8rem'` overrides the default for one column.
213
+
214
+ Declared widths behave as **ratios**, not caps: the table shares its surplus in proportion to them, so a column carrying long values wants the larger number. A table whose columns need more room than its container gets is widened to their sum and scrolls horizontally, rather than crushing the columns.
215
+
216
+ Because a column no longer widens to fit its text, long values wrap and would grow the row instead. The default cell clamps to one line with an ellipsis. **A column rendering its own `spiderlyCellTemplate` clamps its own lines** — the library's rule cannot reach markup that carries the consumer's style encapsulation. Write the clamp against the cell custom properties, with your clamp as the fallback:
217
+
218
+ ```scss
219
+ .my-cell-line {
220
+ overflow: var(--spiderly-cell-overflow, hidden);
221
+ text-overflow: ellipsis;
222
+ white-space: var(--spiderly-cell-white-space, nowrap);
223
+ }
224
+ ```
225
+
226
+ The table sets those two properties on a cell whose column the operator wrapped from the header menu ("wrap text"), and they inherit into projected markup — so a clamp written this way follows the toggle for free, while a hard-coded `white-space: nowrap` turns the menu item into a silent no-op for your column. (`text-overflow` stays literal: it has no effect once `overflow` is `visible`, so the contract doesn't carry it.) Two traps when you write that rule: a flex item needs `min-width: 0` (its default `min-width: auto` floors it at the content width and the ellipsis never fires), and any pill or badge sitting beside the text needs `flex: none` so the text is what gives way.
227
+
228
+ ### Filtering — the filter store and chip bar
229
+
230
+ The table's **whole filter surface is a store the page owns** and hands to `[filters]`; a table given none has no filtering at all (`Column.filterType` describes the value's shape — width, cell rendering — never a filter). Declare the store beside the columns, one filter per question the screen answers:
231
+
232
+ ```typescript
233
+ import { booleanFilter, createFilterStore, dateFilter, MatchModeCodes, numberFilter, textFilter } from 'spiderly';
234
+
235
+ function createProductFilters(t: (key: string) => string) {
236
+ return createFilterStore({
237
+ name: textFilter({ label: t('Name') }),
238
+ price: numberFilter({ label: t('Price') }),
239
+ isActive: booleanFilter({ label: t('IsActive') }),
240
+ // `operators` narrows what the editor offers; the FIRST entry is the default. Typed per
241
+ // kind, so an operator the backend cannot answer is a build error at the declaration.
242
+ createdAt: dateFilter({ label: t('CreatedAt'),
243
+ operators: [MatchModeCodes.GreaterThan, MatchModeCodes.LessThan] }),
244
+ // options make it a pick-list (the `in` operator); only numberFilter accepts them.
245
+ statusId: numberFilter({ label: t('Status'), options: this.statusOptions }),
246
+ });
247
+ }
248
+ ```
249
+
250
+ ```typescript
251
+ filters = createProductFilters((key) => this.translocoService.translate(key));
252
+ ```
253
+
254
+ ```html
255
+ <spiderly-data-table [cols]="cols" [filters]="filters" ...></spiderly-data-table>
256
+ ```
257
+
258
+ The table renders the store as a **chip bar** above the header: one chip per *applied* filter, a read-only sort chip, the result count, `+ Filter` (a searchable popover of every declared filter) and Clear filters. Rules that follow:
259
+
260
+ - **Store ids are backend property names** — they go straight into the paginated request — so they normally repeat the column's `field`. A column whose filter id differs links to it with `Column.filterId` (that is what the header menu's `Filter…` opens); columns never *declare* filters.
261
+ - **Filters live independently of columns.** A filter with no column, or with a hidden one, is still offered and still narrows the grid — the chip is the visible surface, so hiding a column keeps its filter and sort.
262
+ - **`[filters]` is read once, in the table's `ngOnInit`.** A store assigned later is silently ignored; a page that must swap stores at runtime destroys and recreates the table around the swap (`@if` on a view-model field).
263
+ - **Late-arriving options go through `store.setOptions(id, options)`** — it re-resolves the offered operators and open editors read it live. Never write `definitions[id].options` by hand.
264
+ - **Programmatic writes use `setAndCommit(id, { operator, value })`** (a view's `apply`, a bulk-action flow). A bare `set` without `commit` changes only the draft and fails silently.
265
+ - **`offered: false`** keeps a filter out of `+ Filter` when a dedicated control on the page drives it through `filters.get(id)` (render the handle's `value()` draft, `commit()` on your own cadence — a search box, typically). Its chip still renders when applied.
266
+ - **Applied filters persist** under `` `${stateKey}:filters` `` in the `stateStorage` storage (session by default), separately from PrimeNG's own sort/pagination blob under `` `${stateKey}` ``.
267
+ - **A custom editor control for one filter** is a projected `<ng-template spiderlyFilterTemplate="filterId" let-f>`: drive it through the handle (`f.value()`, `f.set(...)`, `f.commit()`). Filters without a template keep the control the bar draws for their kind.
268
+
269
+ **Views** (`[views]`, `TableView[]`) are saved questions rendered as tabs above the bar: each `apply` receives the cleared store (a view is a state, not an addition) and writes with `setAndCommit`; a view whose apply is a function of *now* ("received today") declares `transient: true` so selecting it re-derives instead of restoring yesterday's answer. Column layout (visibility, order, widths, wrap) persists per view.
211
270
 
212
271
  ### Column chooser (show/hide columns)
213
272
 
214
- Lazy tables render a **Columns** toolbar button opening a checkbox list of all data columns. Because a column's header is the table's only filter surface, showing a column is what makes it filterable — declare rarely-needed but filter-worthy columns with `visible: false` (available in the chooser, hidden by default) and pin the row's identifying column with `lockVisible: true`. Rules the table enforces: hiding a column clears its active filter + sort (one reload; plain hides don't hit the server), choices persist per table in `localStorage` under `` `${stateKey}:columns` `` (durable even with `stateStorage: 'session'`; only explicit toggles are stored, so later declared-default changes flow through), the last visible data column can't be hidden, and *Reset to default* restores the declared configuration.
273
+ Lazy tables render a **Columns** toolbar button opening a checkbox list of all data columns. Declare rarely-needed columns with `visible: false` (available in the chooser, hidden by default) and pin the row's identifying column with `lockVisible: true`. Hiding a column never touches its filter or sort — the chip bar is where both stay visible. Choices persist per table in `localStorage` under `` `${stateKey}:columns` `` (durable even with `stateStorage: 'session'`; only explicit toggles are stored, so later declared-default changes flow through), the last visible data column can't be hidden, and *Reset to default* restores the declared configuration.
215
274
 
216
275
  The `onClick` callback receives an `ActionClickEvent` — `{ id, row, element, originalEvent }`. Use `element` (or `originalEvent`) to anchor an overlay/popover to the clicked action; `row` gives you the full row object. It fires only for custom `field` values (never `'Details'` / `'Delete'`).
217
276
 
@@ -264,14 +323,14 @@ Project an `<ng-template spiderlyCellTemplate="field">` to render one column's c
264
323
  ```
265
324
 
266
325
  - Context: `let-row` (`$implicit`, the full row), `let-value="value"` (raw), `let-displayValue="displayValue"` (**what the table would have rendered** — formatted for the column's `filterType` and the app's `LOCALE_ID`, so a template that only decorates the value never re-implements that), `let-col="col"`. `value` / `displayValue` mean the same here as on `CellClickEvent`, so a column carrying both a click handler and a template reads one vocabulary.
267
- - **Cells only.** The header, its filter and its sorting are untouched, and the filter still works on the underlying `field` whatever the template draws.
326
+ - **Cells only.** The header and its sorting are untouched, and the column's filter (a store entry, see Filtering above) works whatever the template draws.
268
327
  - The template binds to the **consuming component**, so its handlers and pipes are yours.
269
328
  - A real `<a [routerLink]>` here beats `onCellClick` for navigation — middle-click and Ctrl+click open a new tab, which a JavaScript `navigate()` does not.
270
329
  - This is also the way out of the enum-cell limit: a `multiselect` column renders the raw stored value in its cell (the table does not map it through `dropdownOrMultiselectValues`), so a template that draws the label from the row's id gets the checkbox filter *and* readable cells.
271
330
 
272
331
  ### Custom Toolbar Actions
273
332
 
274
- Project an `<ng-template spiderlyDataTableActions>` to add your own buttons (or any markup) to the table's toolbar, alongside the built-in Clear Filters / Export to Excel / Reload / Delete Selected buttons. Import `SpiderlyDataTableActionsDirective` in the consuming component.
333
+ Project an `<ng-template spiderlyDataTableActions>` to add your own buttons (or any markup) to the table's toolbar, alongside the built-in Columns / Export to Excel / Reload / Delete Selected buttons. Import `SpiderlyDataTableActionsDirective` in the consuming component.
275
334
 
276
335
  ```html
277
336
  <spiderly-data-table [cols]="cols" [getPaginatedListObservableMethod]="getListMethod">
@@ -292,11 +351,27 @@ Project an `<ng-template spiderlyDataTableActions>` to add your own buttons (or
292
351
 
293
352
  **Shift+click selects a range**: click one checkbox, hold Shift, click another — every row between receives the *clicked checkbox's new state* (shift-checking selects the range, shift-unchecking clears it). The anchor is the last clicked checkbox, and a range only ever spans rows visible on the current page: if the anchor is no longer among them, the shift-click degrades to a plain toggle. (A client-side sort that keeps both rows on screen ranges over the *new* visual order, which is what the user sees and expects.) Rows already in the target state are skipped; each actual change emits `onRowSelect`/`onRowUnselect` exactly as a single click would, so delta-based consumers (`newlySelectedItems`/`unselectedItems`) need no changes.
294
353
 
354
+ ### Paging scrolls the list back into view
355
+
356
+ Changing the page or the page size scrolls the table's wrapper back under the top of the viewport, so the new rows are what you land on rather than the paginator you just clicked. It is instant, never animated, and it does nothing when the table's top is already visible — a table placed below other content on a details page will not push that content off-screen.
357
+
358
+ The amount of fixed chrome to clear comes from the `--spiderly-viewport-top-inset` CSS variable, which `spiderly`'s own layout declares on `html` (defaulting to `--spiderly-topbar-height`). If you run your own fixed header, or pin a second bar below spiderly's topbar, declare the inset to match:
359
+
360
+ ```css
361
+ html {
362
+ --spiderly-viewport-top-inset: 8rem;
363
+ }
364
+ ```
365
+
366
+ Set the inset rather than stretching `--spiderly-topbar-height`, which is the topbar's actual height. Leave it undeclared and the table scrolls to the true top of the viewport, which is correct when nothing is pinned there.
367
+
295
368
  ### Key Inputs
296
369
 
297
370
  | Input | Type | Default | Purpose |
298
371
  | ---------------------------------- | ------------------------ | ------- | ----------------------- |
299
372
  | `cols` | `Column[]` | — | Column definitions |
373
+ | `filters` | `FilterSource` | — | The page's filter store (see Filtering); read once at init |
374
+ | `views` | `TableView[]` | — | Saved questions rendered as tabs above the bar |
300
375
  | `getPaginatedListObservableMethod` | `(filter) => Observable` | — | Server-side data source |
301
376
  | `additionalFilterIdLong` | `number` | — | Parent entity filter |
302
377
  | `hasLazyLoad` | `boolean` | `true` | Server vs client mode |
@@ -14,6 +14,7 @@ Reusable helpers exported from `helper-functions.ts`. Import the one you need in
14
14
  | `exportListToExcel(exportListToExcelObservableMethod: (filter: Filter) => Observable<any>, filter: Filter)` | |
15
15
  | `firstCharToUpper(input: string): string` | |
16
16
  | `getFileNameFromContentDisposition(resp: HttpResponse<Blob>, defaultName: string): string` | |
17
+ | `getFilterOptions(namebookList: Namebook[]): FilterOption[]` | The filter store's twin of `getPrimengNamebookOptions` — same namebook in, the store's `{ value, label }` shape out (a `FilterOption` is not a `PrimengOption`: that one's `code` is deliberate, see `primeng-option.ts`). Every consumer filling a pick-list filter from a lookup needs this map; without it each page re-derives the three lines. |
17
18
  | `getHtmlImgDisplayString64(base64String: string)` | |
18
19
  | `getImageDimensions(file: File): Promise<{ width: number; height: number }>` | |
19
20
  | `getMimeTypeForFileName(fileName: string): string` | |
@@ -33,6 +34,7 @@ Reusable helpers exported from `helper-functions.ts`. Import the one you need in
33
34
  | `parseDateOnlyLocal(s: string): Date \| null` | |
34
35
  | `pushAction(cols: Column[], action: Action)` | |
35
36
  | `saveResponseAsFile(res: HttpResponse<Blob>, fallbackName: string): void` | |
37
+ | `scrollElementIntoViewIfAboveViewport(element: HTMLElement): void` | Scrolls `element` back under the top of the viewport, but only when it has already scrolled above it. Set the element's `scroll-margin-top` to whatever fixed chrome must stay clear. Assumes the element's nearest scroll container is the viewport itself. |
36
38
  | `selectedTab(tabs: SpiderlyTab[]): number` | |
37
39
  | `singleOrDefault<T>(array: T[], predicate: (item: T) => boolean): T \| undefined` | |
38
40
  | `splitPascalCase(input: string)` | |
@@ -93,20 +93,15 @@ The backend allows one CORS origin — `Spiderly.Shared:FrontendUrl` in `appsett
93
93
 
94
94
  Spiderly's admin UI is built on PrimeNG v19. A few selectors that look obvious from the docs do not work — match what's actually rendered.
95
95
 
96
- - **Filter Apply / Clear buttons have no identifying class.** PrimeNG's documented `pcFilterApplyButton` / `pcFilterClearButton` style classes are not applied to the rendered `<p-button>` elements. Match by accessible name:
97
- ```ts
98
- overlay.getByRole('button', { name: 'Apply' })
99
- ```
100
- - **Match-mode dropdown is `<p-select>`, not `<p-dropdown>`** — PrimeNG renamed Dropdown to Select in v19. Spiderly's `<spiderly-dropdown>` wraps `<p-select>` internally.
101
- - **Boolean filter is `<p-checkbox [binary]="true" [indeterminate]="value === null">`**, not `pTriStateCheckbox`. Initial state is `null` (rendered as a horizontal dash); each click cycles `null → true → false → null`.
102
- - **Filter overlays for the rightmost column get clipped against the viewport.** PrimeNG repositions the overlay frame-by-frame, so Playwright's stability check on inner elements fails (`waiting for element to be visible, enabled and stable`). Pass `click({ force: true })` to bypass the stability gate. Apply/Clear buttons (matched by role) do not need this — only the elements *inside* the overlay (e.g. `.p-checkbox-box`).
96
+ - **Operator dropdown is `<p-select>`, not `<p-dropdown>`** — PrimeNG renamed Dropdown to Select in v19. Spiderly's `<spiderly-dropdown>` wraps `<p-select>` internally.
103
97
 
104
- ## Match-mode column configuration
98
+ ## Driving the filter bar
105
99
 
106
- For column-config behavior (when the match-mode dropdown renders, how labels resolve), see `Angular/projects/spiderly/src/lib/components/spiderly-data-table/CLAUDE.md`. Two points that commonly bite test authors:
100
+ Filtering is the chip bar above the table (a `spiderly-data-table` given a filter store), addressed by spiderly's own `data-testid`s — stable by contract, unlike PrimeNG's classes: `add-filter` (the `+ Filter` button; its popover teleports to `document.body`), `add-filter-option` (one filter, matched by label), `filter-editor` (the editor row), `filter-editor-operator` (the operator picker's trigger; its option list is PrimeNG-rendered in a teleported overlay, matched by label), `filter-editor-value` (the editor's value control, whatever its kind draws — input, checkbox, datepicker, multiselect), `filter-editor-apply` (every kind commits through it), `filter-chip`, `filter-chip-remove`, `filter-bar-clear` (renders only while something is applied), `filter-bar-count`, `sort-chip`. The flow is always open → draft → Apply; a draft reaches nothing until Apply, and each Apply re-queries, so await the paginated-list response per commit rather than stacking them.
107
101
 
108
- - **Numeric and date columns need `showMatchModes: true`** on the `Column<T>` for the match-mode `<p-select>` to render at all. Without it the match-mode UI is silently absent and Playwright selectors for "More than" / "Less than" will time out.
109
- - **Match-mode option labels are transloco output** (`'More than'`, `'Less than'`), not `MatchModeCodes` keys. Match Playwright selectors against the value in your `en.json` (or the locale your test runs under).
102
+ - **Operator option labels are transloco output** (`'More than'`, `'Less than'`), not `MatchModeCodes` keys. Match Playwright selectors against the value in your `en.json` (or the locale your test runs under). Which operators a filter offers is the store declaration's `operators` (or the kind's full list).
103
+ - **The boolean editor is a binary `<p-checkbox>`** starting unchecked: one click drafts `true`, a second drafts `false`; commit with Apply.
104
+ - **Applied filters persist under `` `${stateKey}:filters` ``** (the store snapshot: `{ id: { operator, value } }` on the wire vocabulary — `'contains'`, `'greaterThan'`, …); sort and pagination stay in PrimeNG's own blob under `` `${stateKey}` ``. Assert each in its own key.
110
105
 
111
106
  ## Test data: seed and clean
112
107
 
@@ -117,12 +112,12 @@ Tests should own their data. Two patterns:
117
112
 
118
113
  Always use `Promise.all` for seed and cleanup batches. Sequential 40× HTTP round-trips noticeably slow CI; the database has no problem with the concurrency.
119
114
 
120
- ## Generated lists ship with the Id column only
115
+ ## Generated lists ship with the Id column and an Id filter only
121
116
 
122
- `spiderly add-new-entity` produces a list component with a single numeric Id column plus Details/Delete actions. If your test needs to drive text/numeric/boolean filters, you have two options:
117
+ `spiderly add-new-entity` produces a list component with a single numeric Id column, a filter store declaring just the `id` filter, and Details/Delete actions. If your test needs to drive text/boolean filters, you have two options:
123
118
 
124
- 1. **Extend the list component** in your app — add the columns you want to filter on (text, numeric, boolean). The Spiderly admin then has the filter UI your test can target.
125
- 2. **Drive filtering through the API directly** — call the paginated-list endpoint with a `FilterDTO` payload and assert on the response, skipping the UI. Faster, less brittle, but doesn't exercise the column-config code path.
119
+ 1. **Extend the list component** in your app — add the columns and the matching store entries (`textFilter`, `numberFilter`, `booleanFilter`, `dateFilter`) for what you want to filter on. The bar then offers them under `+ Filter`.
120
+ 2. **Drive filtering through the API directly** — call the paginated-list endpoint with a `FilterDTO` payload and assert on the response, skipping the UI. Faster, less brittle, but doesn't exercise the store-config code path.
126
121
 
127
122
  ## Debugging a failing Playwright test in CI
128
123
 
@@ -86,7 +86,16 @@ Use PascalCase for migration names. If the migration looks wrong, `spiderly remo
86
86
 
87
87
  ### List component (`{kebab-name}-list.component.ts`)
88
88
 
89
- Update the `cols` array in `ngOnInit()` with actual entity columns:
89
+ Update **both** the filter store (the `create{EntityName}Filters` function the scaffold declares above the component) and the `cols` array in `ngOnInit()` with actual entity columns. The store is the table's whole filter surface — a column without a matching store entry renders fine but cannot be filtered:
90
+
91
+ ```typescript
92
+ function createProductFilters(t: (key: string) => string) {
93
+ return createFilterStore({
94
+ name: textFilter({ label: t("Name") }),
95
+ price: numberFilter({ label: t("Price") }),
96
+ });
97
+ }
98
+ ```
90
99
 
91
100
  ```typescript
92
101
  this.cols = [
@@ -109,6 +118,8 @@ this.cols = [
109
118
  ];
110
119
  ```
111
120
 
121
+ Store ids are backend property names (they go straight into the paginated request), so they normally repeat the column's `field`. Factories: `textFilter`, `numberFilter` (accepts `options` for a pick-list), `booleanFilter`, `dateFilter` — all imported from `spiderly`.
122
+
112
123
  ### Translation keys
113
124
 
114
125
  Add keys to `Frontend/src/assets/i18n/en.json` (and to any other language files your app uses):