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.
- package/agent/docs/angular-customization/index.md +80 -5
- package/agent/docs/angular-customization/references/helper-functions.generated.md +2 -0
- package/agent/docs/e2e-testing/index.md +10 -15
- package/agent/skills/add-entity/SKILL.md +12 -1
- package/fesm2022/spiderly.mjs +2090 -271
- package/fesm2022/spiderly.mjs.map +1 -1
- package/lib/components/spiderly-data-table/spiderly-data-table.component.d.ts +426 -53
- package/lib/components/spiderly-data-view/spiderly-data-view.component.d.ts +8 -1
- package/lib/directives/spiderly-filter-template.directive.d.ts +34 -0
- package/lib/directives/spiderly-overflow-title.directive.d.ts +33 -0
- package/lib/entities/filter-rule.d.ts +5 -7
- package/lib/filters/allowed-operators.d.ts +23 -0
- package/lib/filters/filter-store.d.ts +255 -0
- package/lib/filters/spiderly-filter-bar.component.d.ts +132 -0
- package/lib/services/helper-functions.d.ts +14 -0
- package/package.json +1 -1
- package/public-api.d.ts +5 -0
- package/styles/components/spiderly-data-table/spiderly-data-table.component.scss +170 -4
- package/styles/components/spiderly-data-view/spiderly-data-view.component.scss +16 -0
- package/styles/filters/spiderly-filter-bar.component.scss +163 -0
- package/styles/styles/layout/_content.scss +1 -1
- package/styles/styles/layout/_main.scss +15 -1
- package/styles/styles/layout/_menu.scss +1 -1
- package/styles/styles/layout/_mixins.scss +46 -0
- package/styles/styles/layout/_topbar.scss +4 -4
|
@@ -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',
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
##
|
|
98
|
+
## Driving the filter bar
|
|
105
99
|
|
|
106
|
-
|
|
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
|
-
- **
|
|
109
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
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):
|