spiderly 19.11.3 → 19.11.4

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.
@@ -22,7 +22,7 @@ Keep admin UI **responsive and mobile-first**, matching the layout/grid conventi
22
22
  Two cross-cutting behaviors ship with every Spiderly admin and cover **every** generated-client call automatically. Re-implementing them per call is the most common needless boilerplate in admin code:
23
23
 
24
24
  - **Loading:** an HTTP interceptor shows the global full-screen blocking spinner for every request and hides it when the request settles. Read-shaped responses (autocomplete, dropdowns, table pagination, bare-scalar GETs) are exempted server-side via `[SkipSpinner]` inference — see the `custom-endpoints` skill to tune that per endpoint. Don't add per-component spinners around API calls; for layout placeholders use `card-skeleton` (catalog below).
25
- - **Errors:** an HTTP interceptor toasts every failed request — server-unreachable warning, the server's `BusinessException` message on 400, login/permission/not-found messages on 401/403/404, a generic error toast for the rest — then **rethrows**, so callers only ever run their success path. Uncaught non-HTTP runtime errors get a generic toast from the global `ErrorHandler`. So: **subscribe to the success path only** — no per-call `catchError` toasts, no `try/catch` around generated client calls. Add `catchError` only when you need to *react* to a specific failure (e.g. roll back optimistic UI state); the user-facing message has already been shown by the time your handler runs.
25
+ - **Errors:** an HTTP interceptor toasts every failed request — server-unreachable warning, the server's `BusinessException` message on 400, login/permission/not-found messages on 401/403/404, a generic error toast for the rest — then **rethrows**, so callers only ever run their success path. Uncaught non-HTTP runtime errors get a generic toast from the global `ErrorHandler`. Both surfaces `console.error` every error, production included — the toast is deliberately vague, so the console is where a developer reads what actually broke. That toast tells the user the team was notified, which is only true if you make it so: **wire an error tracker** by providing an `ErrorHandler` that reports and then delegates to `SpiderlyErrorHandler` (inject it) for the toast. Report non-HTTP errors only — the interceptor already owns HTTP-error UX, and a server 5xx is in your backend's tracker under the same `traceId` the toast shows the user. So: **subscribe to the success path only** — no per-call `catchError` toasts, no `try/catch` around generated client calls. Add `catchError` only when you need to *react* to a specific failure (e.g. roll back optimistic UI state); the user-facing message has already been shown by the time your handler runs.
26
26
 
27
27
  ## Generated File Structure
28
28
 
@@ -138,6 +138,27 @@ Names are PascalCase: `show{PropertyName}For{EntityName}` (plus `showTimeOn{Prop
138
138
 
139
139
  Library components/controls also expose their own `show*` `@Input()`s (e.g. `showLabel` on every control, `showAddButton` on the data table, `showPanelHeader` on the panel) — each a plain boolean, bound the same way: `[showAddButton]="canCreate"`. See the *UI Controls Reference* and *Presentational & Layout Components* tables below for the available inputs.
140
140
 
141
+ ### Replacing a field with your own control (`below*` slots)
142
+
143
+ Each property block also emits an `<ng-content select="[below{PropertyName}For{EntityName}]">` right where the field sits. Pair it with the `show*` input to swap a generated control for your own without editing generated code: hide the generated one, project yours into its slot, and bind yours to the same form control so the save body picks the value up unchanged.
144
+
145
+ ```html
146
+ <user-base-details [parentFormGroup]="parentFormGroup" [showEmailForUser]="false" (onSave)="onSave()">
147
+ <div belowEmailForUser>
148
+ @if (parentFormGroup.controls.userDTO; as userDTO) {
149
+ <spiderly-textbox [control]="userDTO.getControl('email')"></spiderly-textbox>
150
+ }
151
+ </div>
152
+ </user-base-details>
153
+ ```
154
+
155
+ **The `@if` is required, not defensive.** Projected content belongs to *your* view, and Angular creates it with your view — the `@defer` the generated form sits behind does not defer it. So your control renders while `parentFormGroup` is still the empty group `BaseFormComponent` starts with, before `get{Entity}MainUIFormDTO` answers, and `controls.{entity}DTO` is undefined for those first change-detection passes. Unguarded, `controls.{entity}DTO.getControl(...)` throws on each one, and the global `ErrorHandler` turns every throw into the generic error toast. It stops once the data lands, so the symptom is a burst of error toasts on entering the page and then a form that works — easy to misread as a server problem.
156
+
157
+ Two details that decide whether the guard actually works:
158
+
159
+ - **Keep the slot attribute on a real element and put the `@if` inside it.** An `@if` anchor node carries no attribute selector, so wrapping the `<div belowEmailForUser>` in an `@if` drops the content into the default slot instead. (Same rule as conditional `[buttons]`, which needs `<ng-container ngProjectAs="[buttons]">`.)
160
+ - **`?.` alone is not a substitute.** It hands the control `undefined`, which every control's *template* renders fine, but `spiderly-autocomplete`, `spiderly-colorpicker` and `spiderly-checkbox` (with `initializeToFalse`) dereference the control in their own `ngOnInit` and still throw. A component of your own has to be equally tolerant if you rely on `?.` instead of the guard.
161
+
141
162
  ## Data Table
142
163
 
143
164
  ### Lazy Load Mode (server-side pagination, default)
@@ -182,6 +203,10 @@ cols: Column<ProductDTO>[] = [
182
203
  ];
183
204
  ```
184
205
 
206
+ 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.
207
+
208
+ 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.
209
+
185
210
  ### Column chooser (show/hide columns)
186
211
 
187
212
  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.
@@ -192,21 +217,56 @@ For a click on the cell value itself (not an action icon), set a column's `onCel
192
217
 
193
218
  ```typescript
194
219
  new Column({ field: 'total', name: 'Total', filterType: 'numeric',
195
- // Reusing one popover across cells: show() updates the anchor element, but it will NOT move a
196
- // popover that is already open — PrimeNG re-positions via align() only during the open animation,
197
- // which an already-open popover never re-enters (and hide()+show() in the same tick collapses to
198
- // no state change). So after show(), call align() to re-anchor it to the new cell.
199
- onCellClick: (e) => {
200
- const wasOpen = this.itemsPopover.overlayVisible;
201
- this.itemsPopover.show(e.originalEvent, e.element);
202
- if (wasOpen) this.itemsPopover.align();
203
- } }),
220
+ onCellClick: (e) => this.showItems(e) }),
221
+ ```
222
+
223
+ It receives a `CellClickEvent` — `{ id, field, row, value, displayValue, element, originalEvent }`, where `value` is the raw cell value and `displayValue` is the formatted text shown. Only columns that set it become clickable (they get a `cursor`/hover affordance), and the click stops propagation so it does **not** also trigger `navigateOnRowClick`. Anchor overlays with `element` — it's the clicked `<td>`, captured synchronously, so it stays valid inside an async handler (`originalEvent.currentTarget` is null once dispatch ends). Not applied to editable cells.
224
+
225
+ **Reusing ONE popover across cells: close it and reopen it, never re-anchor in place.** Two PrimeNG 19.1.3 bugs make the in-place move (`show()` then `align()`) unfixable from outside the library: `align()` only ever *adds* its flip class and arrow offset and never clears them, so moving from a cell the panel opened above to one it opens below leaves the arrow pointing the wrong way; and PrimeNG emits `(onHide)` from inside its own animation callback, one step *before* it clears `isOverlayAnimationInProgress`, so a `show()` fired from there hits the library's own guard and silently does nothing. Stash the new anchor, `hide()`, and reopen from `(onHide)` deferred by one microtask — every open then runs `align()` once on a fresh container:
226
+
227
+ ```typescript
228
+ onCellClick: (e) => {
229
+ if (this.popover.overlayVisible) {
230
+ this.reopenAnchor = e.element; // re-anchor: close first, reopen from (onHide)
231
+ this.popover.hide();
232
+ } else {
233
+ this.popover.show(e.originalEvent, e.element);
234
+ }
235
+ },
236
+ ```
237
+
238
+ ```typescript
239
+ onPopoverHide(): void {
240
+ const el = this.reopenAnchor;
241
+ if (el == null) return; // ordinary dismiss (outside click / Esc)
242
+ this.reopenAnchor = null;
243
+ queueMicrotask(() => this.popover.show(null, el));
244
+ }
204
245
  ```
205
246
 
206
- It receives a `CellClickEvent` — `{ id, field, row, value, displayValue, element, originalEvent }`, where `value` is the raw cell value and `displayValue` is the formatted text shown. Only columns that set it become clickable (they get a `cursor`/hover affordance), and the click stops propagation so it does **not** also trigger `navigateOnRowClick`. Anchor overlays with `element` — it's the clicked `<td>`, captured synchronously, so it stays valid inside an async handler (`originalEvent.currentTarget` is null once dispatch ends). Not applied to editable cells. (Reusing one popover across rows is the `show()` + `align()` case shown in the snippet above — bare `show()` swaps content but won't move an already-open panel.)
247
+ The cost is a fade-out/fade-in instead of a slide. Re-check on each PrimeNG bump and delete the dance once both bugs are fixed upstream.
207
248
 
208
249
  Style the popover's content at your component's SCSS **top level**, never nested under `:host` — PrimeNG appends the open popover to `document.body`, where `:host`-scoped rules (compiled to `[_nghost] … [_ngcontent]` descendant selectors) silently stop matching; top-level rules keep the `[_ngcontent]` scoping that travels with the moved nodes.
209
250
 
251
+ ### Custom Cell Content (per-column templates)
252
+
253
+ Project an `<ng-template spiderlyCellTemplate="field">` to render one column's cells yourself instead of the plain formatted value. Import `SpiderlyCellTemplateDirective` in the consuming component. Columns with no matching template keep the built-in rendering, so a table opts in one column at a time.
254
+
255
+ ```html
256
+ <spiderly-data-table [cols]="cols" [getPaginatedListObservableMethod]="getListMethod">
257
+ <ng-template spiderlyCellTemplate="orderNumber" let-row let-displayValue="displayValue">
258
+ <a [routerLink]="['/orders', row.id]">{{ displayValue }}</a>
259
+ <div class="secondary">{{ row.itemCount }} items</div>
260
+ </ng-template>
261
+ </spiderly-data-table>
262
+ ```
263
+
264
+ - 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.
265
+ - **Cells only.** The header, its filter and its sorting are untouched, and the filter still works on the underlying `field` whatever the template draws.
266
+ - The template binds to the **consuming component**, so its handlers and pipes are yours.
267
+ - A real `<a [routerLink]>` here beats `onCellClick` for navigation — middle-click and Ctrl+click open a new tab, which a JavaScript `navigate()` does not.
268
+ - 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.
269
+
210
270
  ### Custom Toolbar Actions
211
271
 
212
272
  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.
@@ -12,6 +12,9 @@ Machine-readable error codes returned in ErrorCode. Treat as a public contract
12
12
  | `ExternalEmailMissing` | `external_email_missing` | An external (OAuth/OIDC) login was validated but the provider returned no email address (e.g. the user declined the email permission, or a phone-only Facebook account). Auto-provisioning needs an email to key the account on, so login is rejected with this code and the client should route the user to another sign-in method. Distinct from EmailNotVerified, which means an email was returned but not verified. |
13
13
  | `ExternalProviderNotConfigured` | `external_provider_not_configured` | An external (OAuth) login was attempted for a provider that is not configured on the server, or that provider's token exchange failed. |
14
14
  | `ForeignKeyViolation` | `foreign_key_violation` | A database foreign-key constraint was violated — e.g. referencing a row that does not exist, or deleting a row that is still referenced by dependent rows. |
15
+ | `InvalidMatchMode` | `invalid_match_mode` | A pagination filter rule used a match mode that is not valid for the field's type. Returned with HTTP 400. Thrown via PaginationErrors. |
15
16
  | `InvalidToken` | `invalid_token` | The JWT bearer token is missing, malformed, or expired. Returned with HTTP 401 (also surfaced in the WWW-Authenticate header); the client should refresh the token or re-authenticate. |
16
17
  | `UniqueViolation` | `unique_violation` | A database unique constraint (or unique index) was violated — e.g. saving a duplicate value for a column that must be unique. |
18
+ | `UnknownFilterField` | `unknown_filter_field` | A pagination request filtered on a key with no generated filter case. Returned with HTTP 400; the message names the key and lists the filterable fields. Thrown via PaginationErrors. |
19
+ | `UnknownSortField` | `unknown_sort_field` | A pagination request sorted on a field with no generated sort case (misspelled, or a non-sortable collection column). Returned with HTTP 400; the message names the field and lists the sortable fields. Thrown via PaginationErrors. |
17
20
  | `ValidationFailed` | `validation_failed` | One or more request fields failed server-side validation. Returned with HTTP 400; the per-field messages are carried in ApiErrorDTO.FieldErrors. |
@@ -31,6 +31,8 @@ public class FilterRuleDTO
31
31
 
32
32
  Filter dictionary keys are **camelCase DTO property names**. The generated `PaginatedResultGenerator` auto-resolves them to entity paths (e.g., `categoryDisplayName` → `x.Category.Name`).
33
33
 
34
+ **Unknown fields are rejected, not ignored.** A filter key or `MultiSortMeta.Field` that matches no generated case, and any invalid `MatchMode`, throws `BusinessException` (HTTP 400) with a message naming the offender and listing the valid fields — never a silent no-op (a silently ignored filter would return unfiltered rows the caller treats as filtered). The throws come from `PaginationErrors` (`Spiderly.Shared.Exceptions`) and carry `ApiErrorCodes.UnknownFilterField` / `UnknownSortField` / `InvalidMatchMode` in `ApiErrorDTO.errorCode`, so clients branch on the code rather than the message text. `*CommaSeparated` collection fields are filterable but **not sortable**. If a hand-written override consumes a pseudo filter key the generated switch doesn't know, it must `filterDTO.Filters.Remove("...")` that key before calling `base.GetPaginated{Entity}List(...)`, or the base will reject it.
35
+
34
36
  ## Override `GetPaginated{Entity}List` (Most Common)
35
37
 
36
38
  The most common customization — pre-filter the query before pagination: