@zerotal/admin 1.0.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/package.json +78 -0
  5. package/src/Cluster.ts +50 -0
  6. package/src/Panel.ts +288 -0
  7. package/src/PanelInstance.ts +644 -0
  8. package/src/Resource.ts +918 -0
  9. package/src/actions/Action.ts +607 -0
  10. package/src/actions/ImportRecordsJob.ts +108 -0
  11. package/src/actions/csv.ts +123 -0
  12. package/src/actions/index.ts +39 -0
  13. package/src/actions/render.tsx +181 -0
  14. package/src/actions/transfer.ts +307 -0
  15. package/src/actions/xlsx.ts +304 -0
  16. package/src/auth/AuthLayout.tsx +34 -0
  17. package/src/auth/index.ts +13 -0
  18. package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
  19. package/src/auth/pages/LoginPage.tsx +121 -0
  20. package/src/auth/pages/ProfilePage.tsx +216 -0
  21. package/src/auth/pages/ResetPasswordPage.tsx +103 -0
  22. package/src/auth/pages/VerifyEmailPage.tsx +68 -0
  23. package/src/auth/register.ts +44 -0
  24. package/src/authRoles.ts +141 -0
  25. package/src/commands/MakeAdminResourceCommand.ts +181 -0
  26. package/src/config.ts +128 -0
  27. package/src/dashboardLayout.ts +101 -0
  28. package/src/databaseMedia.ts +148 -0
  29. package/src/databaseNotifications.ts +169 -0
  30. package/src/form/Field.ts +928 -0
  31. package/src/form/ResourceForm.ts +48 -0
  32. package/src/form/Section.ts +364 -0
  33. package/src/form/editors.ts +43 -0
  34. package/src/form/index.ts +59 -0
  35. package/src/history.ts +151 -0
  36. package/src/impersonation.ts +126 -0
  37. package/src/index.ts +380 -0
  38. package/src/infolist/Entry.ts +537 -0
  39. package/src/infolist/Section.ts +99 -0
  40. package/src/infolist/index.ts +38 -0
  41. package/src/media.ts +297 -0
  42. package/src/notifications.ts +65 -0
  43. package/src/pages/AdminPage.ts +100 -0
  44. package/src/pages/ConsolePage.tsx +324 -0
  45. package/src/pages/DashboardPage.tsx +264 -0
  46. package/src/pages/MediaPage.tsx +346 -0
  47. package/src/pages/NotificationsPage.tsx +155 -0
  48. package/src/pages/RecordViewPage.tsx +951 -0
  49. package/src/pages/ResourceFormPage.tsx +1856 -0
  50. package/src/pages/ResourceListPage.tsx +2552 -0
  51. package/src/pages/RolesPage.tsx +325 -0
  52. package/src/pages/SearchPage.tsx +169 -0
  53. package/src/plugin.ts +283 -0
  54. package/src/provider/AdminAbilityMiddleware.ts +25 -0
  55. package/src/provider/AdminGuardMiddleware.ts +29 -0
  56. package/src/provider/AdminProvider.ts +334 -0
  57. package/src/relations/RelationManager.ts +114 -0
  58. package/src/renderHooks.ts +86 -0
  59. package/src/roles.ts +175 -0
  60. package/src/savedViews.ts +79 -0
  61. package/src/support/ability.ts +73 -0
  62. package/src/support/authorize.ts +105 -0
  63. package/src/support/countCache.ts +37 -0
  64. package/src/support/hostPage.ts +30 -0
  65. package/src/table/Column.ts +353 -0
  66. package/src/table/Constraint.ts +238 -0
  67. package/src/table/Filter.ts +275 -0
  68. package/src/table/Group.ts +73 -0
  69. package/src/table/Tab.ts +77 -0
  70. package/src/testing.ts +121 -0
  71. package/src/theme.ts +70 -0
  72. package/src/ui/AdminLayout.tsx +355 -0
  73. package/src/ui/Breadcrumbs.tsx +84 -0
  74. package/src/ui/environmentIndicator.tsx +63 -0
  75. package/src/ui/icons.tsx +124 -0
  76. package/src/widgets/Widget.ts +251 -0
  77. package/src/widgets/render.tsx +154 -0
@@ -0,0 +1,2552 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // The List page for a resource: a URL-driven (search / sort / paginate) table.
3
+ // Sort headers and pagination use flow:navigate, which re-seeds the `@url`
4
+ // properties and re-renders server-side — no API, no client store.
5
+
6
+ import { Component, url, expose, locked } from "@zerotal/flow";
7
+ import type { HtmlNode } from "@zerotal/flow";
8
+ import type { HttpContext } from "@zerotal/core";
9
+ import { Table, DropdownMenu, Dialog, Pagination, Empty, Calendar, isoDay } from "@zerotal/flow-ui";
10
+ import type { TableColumn, TableGroup } from "@zerotal/flow-ui";
11
+ import { RuleBuilder, runValidation } from "@zerotal/validator";
12
+ import type { Schema } from "@zerotal/validator";
13
+ import type { Field } from "../form/index.ts";
14
+ import { AdminLayout, makeAdminLayout } from "../ui/AdminLayout.tsx";
15
+ import { Breadcrumbs, resourceTrail } from "../ui/Breadcrumbs.tsx";
16
+ import { renderWidgets } from "../widgets/render.tsx";
17
+ import { viewQuery, viewIsActive } from "../savedViews.ts";
18
+ import { resolveRenderHooks } from "../renderHooks.ts";
19
+ import { widgetPollInterval } from "../widgets/Widget.ts";
20
+ import { Icon } from "../ui/icons.tsx";
21
+ import type { ResourceClass } from "../Panel.ts";
22
+ import { Panel } from "../Panel.ts";
23
+ import type { PanelInstance } from "../PanelInstance.ts";
24
+ import type { Column, BadgeTone } from "../table/Column.ts";
25
+ import type { Group } from "../table/Group.ts";
26
+ import type { Filter, QueryRule } from "../table/Filter.ts";
27
+ import {
28
+ parseRuleTree,
29
+ ruleTreeIsEmpty,
30
+ describeRuleTree,
31
+ selectFilter,
32
+ ternaryFilter,
33
+ textFilter,
34
+ } from "../table/Filter.ts";
35
+ import type { Constraint } from "../table/Constraint.ts";
36
+ import type { AdminRecord, AdminQuery, ListOptions } from "../Resource.ts";
37
+ import {
38
+ Action,
39
+ ActionGroup,
40
+ flattenActions,
41
+ renderAction,
42
+ renderActionGroup,
43
+ renderActionMenuItem,
44
+ restoreAction,
45
+ forceDeleteAction,
46
+ bulkRestoreAction,
47
+ bulkForceDeleteAction,
48
+ } from "../actions/index.ts";
49
+ import type { ActionContext, ActionPage, ActionItem } from "../actions/index.ts";
50
+ import { rememberTabCounts } from "../support/countCache.ts";
51
+ import { assertCan, assertActionAllowed, AdminForbiddenError } from "../support/authorize.ts";
52
+ import { resolveMediaSrc } from "../media.ts";
53
+
54
+ const BADGE_CLASS: Record<BadgeTone, string> = {
55
+ default: "bg-secondary text-secondary-foreground",
56
+ primary: "bg-primary/10 text-primary ring-1 ring-inset ring-primary/20",
57
+ success: "bg-success/10 text-success ring-1 ring-inset ring-success/20",
58
+ muted: "bg-muted text-muted-foreground",
59
+ destructive: "bg-destructive/10 text-destructive ring-1 ring-inset ring-destructive/20",
60
+ };
61
+
62
+ /** Is this record soft-deleted? Prefers the model's `trashed()`; falls back to `deleted_at`. */
63
+ function isTrashed(rec: Record<string, unknown> | undefined): boolean {
64
+ if (!rec) return false;
65
+ const t = (rec as { trashed?: () => boolean }).trashed;
66
+ if (typeof t === "function") return t.call(rec);
67
+ return rec["deleted_at"] != null;
68
+ }
69
+
70
+ /**
71
+ * Read a chosen file into the hidden field the action form is bound to.
72
+ *
73
+ * Registered once per page; the `input` event is what makes the binding notice
74
+ * the new value, so it must be dispatched rather than just assigning `.value`.
75
+ */
76
+ const FILE_READER_SCRIPT = `(function(){
77
+ if (window.__zerotalReadFile) return;
78
+ window.__zerotalReadFile = function(input, targetId){
79
+ var target = document.getElementById(targetId);
80
+ var file = input.files && input.files[0];
81
+ if (!target) return;
82
+ if (!file) { target.value = ''; target.dispatchEvent(new Event('input', {bubbles:true})); return; }
83
+ var reader = new FileReader();
84
+ reader.onload = function(){
85
+ target.value = String(reader.result || '');
86
+ target.dispatchEvent(new Event('input', {bubbles:true}));
87
+ };
88
+ reader.readAsText(file);
89
+ };
90
+ })();`;
91
+
92
+ /** A fresh, empty top-level rule group for a query-builder filter. */
93
+ function emptyRuleGroup(): Extract<QueryRule, { type: "group" }> {
94
+ return { type: "group", operator: "and", rules: [] };
95
+ }
96
+
97
+ /** Decode the URL `filters` param (JSON map) into a `{ key: value }` object. */
98
+ function parseFilters(s: string): Record<string, string> {
99
+ if (!s) return {};
100
+ try {
101
+ const o = JSON.parse(s) as unknown;
102
+ return o && typeof o === "object" ? (o as Record<string, string>) : {};
103
+ } catch {
104
+ return {};
105
+ }
106
+ }
107
+
108
+ export class ResourceListPage extends Component {
109
+ static layout = AdminLayout;
110
+ /** Set by each generated subclass. */
111
+ static resource: ResourceClass;
112
+ /** The panel this page belongs to — set by each generated subclass. */
113
+ static panel: PanelInstance;
114
+
115
+ @url search = "";
116
+ @url sortBy = "";
117
+ @url sortDir: "asc" | "desc" = "asc";
118
+ /**
119
+ * Additional sorts, as `col:dir,col:dir`. The header click drives `sortBy`;
120
+ * this carries the tie-breakers beneath it, so "by status, then newest first"
121
+ * is a link rather than a saved query somewhere.
122
+ */
123
+ @url sort = "";
124
+ @url page = "1";
125
+ @url tab = "";
126
+ /** Active filters, JSON-encoded `{ filterKey: value }`. */
127
+ @url filters = "";
128
+ /** Soft-delete scope: "" (active), "with" (incl. trashed), "only" (trashed). */
129
+ @url trashed = "";
130
+ /** Page size override (empty = the resource default). */
131
+ @url perPage = "";
132
+ /** Hidden column keys, comma-joined (column-visibility manager). */
133
+ @url cols = "";
134
+ /** Active row grouping column ("" = none). */
135
+ @url group = "";
136
+ /** Active locale, for a translatable resource. */
137
+ @url locale = "";
138
+
139
+ /**
140
+ * The parent record's id, for a resource nested under another. Locked rather
141
+ * than URL-derived so WebSocket actions stay scoped to the same parent.
142
+ */
143
+ @locked parentId = "";
144
+
145
+ /** Selected row ids for bulk actions (reactive — survives round-trips). */
146
+ @expose selected: string[] = [];
147
+
148
+ /**
149
+ * Working copy of each query-builder filter's rule tree, keyed by filter.
150
+ *
151
+ * Editing happens here rather than in the URL so a half-built rule doesn't
152
+ * re-query on every keystroke; "Apply" is what writes it to `?filters=`.
153
+ */
154
+ @expose builderDrafts: Record<string, QueryRule> = {};
155
+
156
+ /** Name being typed into the "save this view" box. */
157
+ @expose newViewName = "";
158
+
159
+ override async onMount(ctx?: HttpContext): Promise<void> {
160
+ this._seedBuilderDrafts();
161
+
162
+ const parent = this._resource.parent;
163
+ if (!parent) return;
164
+ const raw = ctx?.params?.[this._resource.parentParam()];
165
+ if (raw == null) return; // keep any pre-seeded id (e.g. tests)
166
+ // An implicitly-bound model resolves to an object; otherwise it's the raw segment.
167
+ this.parentId = String(
168
+ raw && typeof raw === "object"
169
+ ? (raw as Record<string, unknown>)[this._resource.parentResource()!.primaryKey]
170
+ : raw,
171
+ );
172
+ }
173
+
174
+ /** Start each builder from whatever the URL already has applied. */
175
+ private _seedBuilderDrafts(): void {
176
+ const active = parseFilters(this.filters);
177
+ for (const f of this._resource.filters()) {
178
+ if (f._type !== "builder") continue;
179
+ const parsed = parseRuleTree(active[f._key] ?? "");
180
+ this.builderDrafts[f._key] = parsed && parsed.type === "group" ? parsed : emptyRuleGroup();
181
+ }
182
+ }
183
+
184
+ /** The draft group for a filter, created on first use. */
185
+ private _draft(key: string): Extract<QueryRule, { type: "group" }> {
186
+ const existing = this.builderDrafts[key];
187
+ if (existing && existing.type === "group") return existing;
188
+ const fresh = emptyRuleGroup();
189
+ this.builderDrafts[key] = fresh;
190
+ return fresh;
191
+ }
192
+
193
+ /**
194
+ * Walk to the group a path names. `""` is the root; `"2"` is the third child of
195
+ * the root, and so on. An unreachable path resolves to the root rather than
196
+ * throwing, so a stale click after a removal is harmless.
197
+ */
198
+ private _groupAt(key: string, path: string): Extract<QueryRule, { type: "group" }> {
199
+ let node = this._draft(key);
200
+ if (!path) return node;
201
+ for (const segment of path.split(".")) {
202
+ const child = node.rules[Number(segment)];
203
+ if (!child || child.type !== "group") return node;
204
+ node = child;
205
+ }
206
+ return node;
207
+ }
208
+
209
+ @expose addBuilderRule(key: unknown, path: unknown): void {
210
+ const filterKey = String(key);
211
+ const constraint = this._builderFilter(filterKey)?._constraints[0];
212
+ if (!constraint) return;
213
+ this._groupAt(filterKey, String(path ?? "")).rules.push({
214
+ type: "rule",
215
+ constraint: constraint._key,
216
+ operator: constraint.operators()[0]?.value ?? "equals",
217
+ value: "",
218
+ });
219
+ }
220
+
221
+ @expose addBuilderGroup(key: unknown, path: unknown): void {
222
+ const group = this._groupAt(String(key), String(path ?? ""));
223
+ // A new group starts with one rule; an empty one has nothing to show.
224
+ group.rules.push({ type: "group", operator: "or", rules: [] });
225
+ this.addBuilderRule(key, `${String(path ?? "")}${path ? "." : ""}${group.rules.length - 1}`);
226
+ }
227
+
228
+ @expose removeBuilderRule(key: unknown, path: unknown, index: unknown): void {
229
+ this._groupAt(String(key), String(path ?? "")).rules.splice(Number(index), 1);
230
+ }
231
+
232
+ @expose setBuilderGroupOperator(key: unknown, path: unknown, operator: unknown): void {
233
+ this._groupAt(String(key), String(path ?? "")).operator = operator === "or" ? "or" : "and";
234
+ }
235
+
236
+ /** Update one field of one rule (its constraint, operator or value). */
237
+ @expose setBuilderRule(
238
+ key: unknown,
239
+ path: unknown,
240
+ index: unknown,
241
+ field: unknown,
242
+ value: unknown,
243
+ ): void {
244
+ const filterKey = String(key);
245
+ const rule = this._groupAt(filterKey, String(path ?? "")).rules[Number(index)];
246
+ if (!rule || rule.type !== "rule") return;
247
+
248
+ if (field === "constraint") {
249
+ rule.constraint = String(value);
250
+ // Operators differ per constraint kind, so the old one may not exist here.
251
+ const constraint = this._constraintFor(filterKey, rule.constraint);
252
+ rule.operator = constraint?.operators()[0]?.value ?? "equals";
253
+ rule.value = "";
254
+ } else if (field === "operator") {
255
+ rule.operator = String(value);
256
+ const constraint = this._constraintFor(filterKey, rule.constraint);
257
+ if (constraint?.isUnary(rule.operator)) rule.value = "";
258
+ } else {
259
+ rule.value = String(value);
260
+ }
261
+ }
262
+
263
+ /** Commit a builder draft to the URL, which re-runs the query. */
264
+ @expose async applyBuilder(key: unknown): Promise<void> {
265
+ const filterKey = String(key);
266
+ const draft = this._draft(filterKey);
267
+ const encoded = ruleTreeIsEmpty(draft) ? "" : JSON.stringify(draft);
268
+ await this.navigateCurrent({ query: this._filterParams(filterKey, encoded) });
269
+ }
270
+
271
+ /** Clear a builder entirely — draft and applied. */
272
+ @expose async clearBuilder(key: unknown): Promise<void> {
273
+ const filterKey = String(key);
274
+ this.builderDrafts[filterKey] = emptyRuleGroup();
275
+ await this.navigateCurrent({ query: this._filterParams(filterKey, "") });
276
+ }
277
+
278
+ private _builderFilter(key: string): Filter | undefined {
279
+ return this._resource.filters().find((f) => f._key === key && f._type === "builder");
280
+ }
281
+
282
+ private _constraintFor(filterKey: string, constraintKey: string): Constraint | undefined {
283
+ return this._builderFilter(filterKey)?._constraints.find((c) => c._key === constraintKey);
284
+ }
285
+
286
+ /** Scope the query to the parent record, for a nested resource. */
287
+ private _scopeToParent(query: AdminQuery): AdminQuery {
288
+ const parent = this._resource.parent;
289
+ if (!parent || !this.parentId) return query;
290
+ return query.where(parent.foreignKey, this.parentId);
291
+ }
292
+
293
+ /** Inline toggle-column edit: flip a boolean column on a record. */
294
+ @expose async toggleColumn(id: unknown, column: unknown, value: unknown): Promise<void> {
295
+ const R = this._resource;
296
+ const name = String(column);
297
+ // `column` is client-supplied and was written straight into an UPDATE, making this an
298
+ // arbitrary-column write: `toggleColumn(1, "is_admin", true)` on any row the panel lists.
299
+ // Only columns the resource declares as toggles are writable here.
300
+ const col = R.columns().find((c) => c._key === name && c._kind === "toggle");
301
+ if (!col) throw new AdminForbiddenError(`column "${name}" is not an editable toggle`);
302
+ const record = (await R.find(id)) as Record<string, unknown> | null;
303
+ assertCan(R, "update", record ?? undefined);
304
+ await R.update(id, { [name]: Boolean(value) });
305
+ }
306
+
307
+ /** Per-cell draft values for inline select/text editing, keyed `id__column`. */
308
+ @expose cellEdits: Record<string, unknown> = {};
309
+
310
+ /**
311
+ * Persist an inline-edited cell. The new value is already synced into
312
+ * `cellEdits[key]` by Flow's model binding (input fires before change), so we
313
+ * read it back, write it, and drop the draft so the cell reseeds from the row.
314
+ */
315
+ @expose async saveCell(id: unknown, column: unknown, key: unknown): Promise<void> {
316
+ const R = this._resource;
317
+ const name = String(column);
318
+ // Same arbitrary-column hazard as toggleColumn: restrict to the inline-editable kinds the
319
+ // table actually renders as editors.
320
+ const col = R.columns().find(
321
+ (c) => c._key === name && (c._kind === "input" || c._kind === "select"),
322
+ );
323
+ if (!col) throw new AdminForbiddenError(`column "${name}" is not inline-editable`);
324
+ const record = (await R.find(id)) as Record<string, unknown> | null;
325
+ assertCan(R, "update", record ?? undefined);
326
+ const k = String(key);
327
+ const value = this.cellEdits[k];
328
+ await R.update(id, { [name]: value });
329
+ delete this.cellEdits[k];
330
+ }
331
+
332
+ /**
333
+ * Reorder a row up (-1) / down (+1) by swapping its position-column value with
334
+ * the adjacent row's. Rows are ordered globally
335
+ * by the position column; the swap persists via two updates.
336
+ */
337
+ @expose async moveRow(id: unknown, dir: unknown): Promise<void> {
338
+ const R = this._resource;
339
+ const col = R.reorderable;
340
+ if (!col) return;
341
+ assertCan(R, "update");
342
+ const pk = R.primaryKey;
343
+ const all = await R.listAll({ sortBy: col, sortDir: "asc" });
344
+ const i = all.findIndex((r) => String(r[pk]) === String(id));
345
+ const j = i + Number(dir);
346
+ if (i < 0 || j < 0 || j >= all.length) return;
347
+ const a = all[i]!;
348
+ const b = all[j]!;
349
+ const numA = Number(a[col]);
350
+ const numB = Number(b[col]);
351
+ // Seed positions from indices when missing/equal so the swap actually moves it.
352
+ const posA = Number.isFinite(numA) ? numA : i;
353
+ const posB = Number.isFinite(numB) ? numB : j;
354
+ if (posA === posB) {
355
+ await R.update(a[pk], { [col]: j });
356
+ await R.update(b[pk], { [col]: i });
357
+ } else {
358
+ await R.update(a[pk], { [col]: posB });
359
+ await R.update(b[pk], { [col]: posA });
360
+ }
361
+ }
362
+
363
+ private get _resource(): ResourceClass {
364
+ return (this.constructor as unknown as { resource: ResourceClass }).resource;
365
+ }
366
+
367
+ /**
368
+ * The panel this page was generated for. Held on the class rather than resolved
369
+ * from the request, so WebSocket actions — which carry no URL — stay on it.
370
+ */
371
+ private get _panel(): PanelInstance {
372
+ return (this.constructor as typeof ResourceListPage).panel ?? Panel.current();
373
+ }
374
+
375
+ // ── Action context + dispatch ───────────────────────────────────────────────
376
+
377
+ private _ctxBase(): ActionContext {
378
+ const R = this._resource;
379
+ return {
380
+ resource: R,
381
+ page: this as unknown as ActionPage,
382
+ base: this._panel.base(),
383
+ slug: R.getSlug(),
384
+ panelId: this._panel.id,
385
+ parentId: this.parentId || undefined,
386
+ listOptions: this._listOptions(),
387
+ };
388
+ }
389
+
390
+ /**
391
+ * How the table is currently scoped — everything but pagination.
392
+ *
393
+ * Rebuilt from the page's own `@url`/`@locked` state rather than captured
394
+ * during render, because actions arrive over the WebSocket after the render
395
+ * that drew their button is long gone.
396
+ */
397
+ /**
398
+ * Filters derived from columns marked {@link Column.filterable}.
399
+ *
400
+ * Keyed `col:<column>` so a header filter can never collide with a declared
401
+ * one of the same name, and so the filter bar can tell the two apart — header
402
+ * filters belong in the header, not stacked above the table twice.
403
+ */
404
+ private _headerFilters(): Filter[] {
405
+ return this._resource
406
+ .columns()
407
+ .filter((c) => c._filterable)
408
+ .map((c) => {
409
+ const key = `col:${c._key}`;
410
+ const filter =
411
+ c._kind === "toggle"
412
+ ? ternaryFilter(key)
413
+ : c._options?.length
414
+ ? selectFilter(key).options(
415
+ c._options.map((o) => ({ value: o.value, label: o.label })),
416
+ )
417
+ : textFilter(key);
418
+ return filter.column(c._key).label(c.getLabel());
419
+ });
420
+ }
421
+
422
+ /** Everything that can narrow the query: declared filters and header filters. */
423
+ private _allFilters(): Filter[] {
424
+ return [...this._resource.filters(), ...this._headerFilters()];
425
+ }
426
+
427
+ private _listOptions(): ListOptions {
428
+ const R = this._resource;
429
+ const tabs = R.tabs();
430
+ const activeTab = tabs.find((t) => t._key === (this.tab || tabs[0]?._key || ""));
431
+ const resourceFilters = this._allFilters();
432
+ const active = parseFilters(this.filters);
433
+ const sortBy = this.sortBy || R.defaultSort?.column || "";
434
+
435
+ return {
436
+ search: this.search || undefined,
437
+ sortBy: sortBy || undefined,
438
+ sortDir: this.sortDir === "desc" ? "desc" : "asc",
439
+ trashed:
440
+ R.usesSoftDeletes() && (this.trashed === "with" || this.trashed === "only")
441
+ ? this.trashed
442
+ : undefined,
443
+ modifyQuery: (q) => {
444
+ q = this._scopeToParent(q);
445
+ if (activeTab?._modify) q = activeTab._modify(q);
446
+ for (const f of resourceFilters) {
447
+ const v = active[f._key];
448
+ if (v != null && v !== "") q = f.apply(q, v);
449
+ }
450
+ return q;
451
+ },
452
+ };
453
+ }
454
+
455
+ private _ctx(record?: Record<string, unknown>): ActionContext {
456
+ return { ...this._ctxBase(), record: record as AdminRecord | undefined };
457
+ }
458
+
459
+ /** Run a single-record callback action resolved by key. */
460
+ @expose async runAction(key: unknown, id: unknown): Promise<void> {
461
+ const R = this._resource;
462
+ const act = flattenActions([...this._rowActions(), ...R.headerActions()]).find(
463
+ (a) => a._key === key,
464
+ );
465
+ if (!act?._handler) return;
466
+ const record = id !== "" && id != null ? await R.find(id) : undefined;
467
+ const ctx = this._ctx((record as Record<string, unknown>) ?? undefined);
468
+ // The action's own visible/authorize predicates decide whether the button is rendered;
469
+ // they must decide whether the call runs, too. Without this, hiding the button was the
470
+ // entire control and any admin-page visitor could invoke it with arguments of their choice.
471
+ assertActionAllowed(act, record as Record<string, unknown> | undefined, ctx);
472
+ await act.execute(ctx);
473
+ }
474
+
475
+ /** Run a bulk action over the current selection, then clear it. */
476
+ @expose async runBulkAction(key: unknown): Promise<void> {
477
+ const act = flattenActions(this._bulkActions()).find((a) => a._key === key);
478
+ if (!act?._handler) return;
479
+ const ctx = this._ctxBase();
480
+ // bulkDeleteAction/bulkForceDeleteAction declare no .authorize() of their own, so the
481
+ // resource ability is asserted explicitly as well as the action predicate.
482
+ assertActionAllowed(act, undefined, ctx as ActionContext);
483
+ if (act._key === "bulk-delete") assertCan(this._resource, "delete");
484
+ if (act._key === "bulk-force-delete") assertCan(this._resource, "forceDelete");
485
+ await act.execute({ ...ctx, ids: [...this.selected] });
486
+ this.selected = [];
487
+ }
488
+
489
+ /**
490
+ * Row actions for the current view. Soft-delete resources gate Delete to active
491
+ * rows and add Restore / Force-delete on trashed rows (per-row visibility).
492
+ */
493
+ private _rowActions(): ActionItem[] {
494
+ const R = this._resource;
495
+ if (!R.usesSoftDeletes()) return R.recordActions();
496
+ const base = R.recordActions().map((a) =>
497
+ a instanceof Action && a._key === "delete"
498
+ ? a.visible((rec) => !isTrashed(rec as Record<string, unknown>))
499
+ : a,
500
+ );
501
+ return [
502
+ ...base,
503
+ restoreAction().visible((rec) => isTrashed(rec as Record<string, unknown>)),
504
+ forceDeleteAction().visible((rec) => isTrashed(rec as Record<string, unknown>)),
505
+ ];
506
+ }
507
+
508
+ /** Bulk actions for the current view (restore / force-delete on the trashed views). */
509
+ private _bulkActions(): ActionItem[] {
510
+ const R = this._resource;
511
+ if (!R.usesSoftDeletes()) return R.bulkActions();
512
+ if (this.trashed === "only") return [bulkRestoreAction(), bulkForceDeleteAction()];
513
+ if (this.trashed === "with")
514
+ return [...R.bulkActions(), bulkRestoreAction(), bulkForceDeleteAction()];
515
+ return R.bulkActions();
516
+ }
517
+
518
+ /** Currently hidden column keys. */
519
+ private _hiddenCols(): Set<string> {
520
+ return new Set(this.cols ? this.cols.split(",").filter(Boolean) : []);
521
+ }
522
+
523
+ /** Build a URL that toggles a column's visibility (keeps the current page). */
524
+ private _colHref(key: string): string {
525
+ const hidden = this._hiddenCols();
526
+ if (hidden.has(key)) hidden.delete(key);
527
+ else hidden.add(key);
528
+ const sp = this._params();
529
+ const enc = [...hidden].join(",");
530
+ if (enc) sp.set("cols", enc);
531
+ else sp.delete("cols");
532
+ return "?" + sp.toString();
533
+ }
534
+
535
+ /** Build a URL that sets the page size (resets to page 1). */
536
+ private _perPageHref(n: number): string {
537
+ const sp = this._params({ page: undefined });
538
+ sp.set("perPage", String(n));
539
+ return "?" + sp.toString();
540
+ }
541
+
542
+ /** Build a URL that sets/clears the active grouping (resets to page 1). */
543
+ private _groupHref(column: string): string {
544
+ const sp = this._params({ page: undefined });
545
+ if (column) sp.set("group", column);
546
+ else sp.delete("group");
547
+ return "?" + sp.toString();
548
+ }
549
+
550
+ /** Build a URL that switches the trashed scope (resets to page 1). */
551
+ private _trashedHref(mode: string): string {
552
+ const sp = this._params({ page: undefined });
553
+ if (mode) sp.set("trashed", mode);
554
+ else sp.delete("trashed");
555
+ return "?" + sp.toString();
556
+ }
557
+
558
+ /** Switch the locale the list reads translatable columns in. */
559
+ private _localeHref(code: string): string {
560
+ const sp = this._params({ page: undefined });
561
+ // The default locale is the absent value, so the common URL stays clean.
562
+ if (code && code !== this._resource.locales[0]) sp.set("locale", code);
563
+ else sp.delete("locale");
564
+ return "?" + sp.toString();
565
+ }
566
+
567
+ // ── Selection ────────────────────────────────────────────────────────────────
568
+
569
+ @expose toggleSelect(id: unknown): void {
570
+ const key = String(id);
571
+ this.selected = this.selected.includes(key)
572
+ ? this.selected.filter((x) => x !== key)
573
+ : [...this.selected, key];
574
+ }
575
+
576
+ @expose toggleSelectAll(ids: unknown): void {
577
+ const all = (Array.isArray(ids) ? ids : []).map(String);
578
+ const allSelected = all.length > 0 && all.every((id) => this.selected.includes(id));
579
+ this.selected = allSelected ? [] : all;
580
+ }
581
+
582
+ @expose clearSelection(): void {
583
+ this.selected = [];
584
+ }
585
+
586
+ // ── Modal-form actions ──────────────────────────────────────────────────────
587
+
588
+ /** Whether the action modal is open. */
589
+ @expose actionModalOpen = false;
590
+ /** Key of the action whose modal is open. */
591
+ @expose actionFormKey = "";
592
+ /** Record id the modal acts on ("" for header/bulk actions). */
593
+ @expose actionFormId = "";
594
+ /** Reactive values bound to the modal form fields. */
595
+ @expose actionForm: Record<string, unknown> = {};
596
+ /** Per-field validation messages for the modal form. */
597
+ @expose actionErrors: Record<string, string> = {};
598
+
599
+ /** Resolve a form-bearing action by key across row / header / bulk sets. */
600
+ private _resolveFormAction(key: string): Action | undefined {
601
+ const R = this._resource;
602
+ return flattenActions([
603
+ ...this._rowActions(),
604
+ ...R.headerActions(),
605
+ ...this._bulkActions(),
606
+ ]).find((a) => a._key === key && a.hasForm());
607
+ }
608
+
609
+ /** Open an action's modal form, seeding defaults (and the record for row actions). */
610
+ @expose async openActionForm(key: unknown, id: unknown): Promise<void> {
611
+ const act = this._resolveFormAction(String(key));
612
+ if (!act) return;
613
+ // Seeds record fields into the modal state, so an ungated call is a read primitive as
614
+ // well as a step towards submitActionForm.
615
+ const seedRecord =
616
+ id !== "" && id != null
617
+ ? ((await this._resource.find(id)) as Record<string, unknown>)
618
+ : undefined;
619
+ assertActionAllowed(act, seedRecord, this._ctx(seedRecord));
620
+ const fields = act.fieldsFor(this.actionForm, this._resource);
621
+ const form: Record<string, unknown> = {};
622
+ for (const f of fields) form[f._key] = f.defaultValue();
623
+ if (id !== "" && id != null) {
624
+ const rec = await this._resource.find(id);
625
+ if (rec) {
626
+ for (const f of fields) {
627
+ if (f._key in (rec as Record<string, unknown>)) {
628
+ form[f._key] = f.hydrate((rec as Record<string, unknown>)[f._key]);
629
+ }
630
+ }
631
+ }
632
+ }
633
+ this.actionForm = form;
634
+ this.actionErrors = {};
635
+ this.actionFormKey = String(key);
636
+ this.actionFormId = id != null ? String(id) : "";
637
+ this.actionModalOpen = true;
638
+ }
639
+
640
+ @expose closeActionForm(): void {
641
+ this.actionModalOpen = false;
642
+ }
643
+
644
+ /** Validate the modal form against its fields' rules; returns field → message. */
645
+ private _validateActionForm(
646
+ fields: Field[],
647
+ data: Record<string, unknown>,
648
+ ): Record<string, string> {
649
+ const v = new RuleBuilder();
650
+ const schema: Schema = {};
651
+ for (const f of fields) {
652
+ schema[f._key] = (f.buildRule(v) as unknown as { _def: Schema[string] })._def;
653
+ }
654
+ const result = runValidation(schema, data);
655
+ return result.success ? {} : (result.errors as Record<string, string>);
656
+ }
657
+
658
+ /** Validate + run the open action's handler with the submitted form data. */
659
+ @expose async submitActionForm(): Promise<void> {
660
+ const R = this._resource;
661
+ const act = this._resolveFormAction(this.actionFormKey);
662
+ if (!act?._handler) {
663
+ this.actionModalOpen = false;
664
+ return;
665
+ }
666
+ const fields = act.fieldsFor(this.actionForm, this._resource);
667
+ const data: Record<string, unknown> = { ...this.actionForm };
668
+ const errors = this._validateActionForm(fields, data);
669
+ if (Object.keys(errors).length > 0) {
670
+ this.actionErrors = errors;
671
+ return;
672
+ }
673
+ for (const f of fields) if (f._key in data) data[f._key] = await f.dehydrate(data[f._key]);
674
+
675
+ const ctx: ActionContext = act._bulk
676
+ ? { ...this._ctxBase(), ids: [...this.selected], data }
677
+ : {
678
+ ...this._ctxBase(),
679
+ record: (this.actionFormId
680
+ ? ((await R.find(this.actionFormId)) as AdminRecord | null)
681
+ : undefined) as AdminRecord | undefined,
682
+ data,
683
+ };
684
+ assertActionAllowed(act, ctx.record as Record<string, unknown> | undefined, ctx);
685
+ await act.execute(ctx);
686
+ if (act._bulk) this.selected = [];
687
+ this.actionModalOpen = false;
688
+ }
689
+
690
+ /** Render one modal-form control bound to `actionForm` (common field types). */
691
+ private _modalControl(f: Field): HtmlNode {
692
+ const form = this.actionForm;
693
+ const cls =
694
+ "mt-1.5 block w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:ring-2 focus:ring-ring";
695
+ if (f._type === "textarea") {
696
+ return (
697
+ <textarea value={form[f._key]} rows={f._rows} placeholder={f._placeholder} class={cls} />
698
+ );
699
+ }
700
+ if (f._type === "select") {
701
+ const cur = String(form[f._key] ?? "");
702
+ return (
703
+ <select value={form[f._key]} class={cls}>
704
+ <option value="">{f._placeholder ?? "Select…"}</option>
705
+ {(f._options ?? []).map((o) => (
706
+ <option value={o.value} selected={String(o.value) === cur}>
707
+ {o.label}
708
+ </option>
709
+ ))}
710
+ </select>
711
+ );
712
+ }
713
+ if (f._type === "file") {
714
+ // The action handler wants the file's *contents*, not a stored path, so the
715
+ // browser reads it and fills a hidden bound field. Dispatching `input` is
716
+ // what tells the binding the value changed — assigning `.value` alone is
717
+ // invisible to it.
718
+ const id = `kfile-${f._key}`;
719
+ return (
720
+ <div class="mt-1.5">
721
+ <input
722
+ type="file"
723
+ id={id}
724
+ accept={f._accept ?? undefined}
725
+ onchange={`window.__zerotalReadFile(this,'${id}-value')`}
726
+ class="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-secondary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-secondary-foreground hover:file:bg-accent"
727
+ />
728
+ <textarea id={`${id}-value`} value={form[f._key]} class="hidden" />
729
+ </div>
730
+ );
731
+ }
732
+ if (f._type === "checkbox" || f._type === "toggle") {
733
+ return (
734
+ <label class="mt-1.5 inline-flex items-center gap-2">
735
+ <input
736
+ type="checkbox"
737
+ checked={form[f._key]}
738
+ class="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-ring"
739
+ />
740
+ <span class="text-sm text-muted-foreground">{f._placeholder ?? f.getLabel()}</span>
741
+ </label>
742
+ );
743
+ }
744
+ const type =
745
+ f._type === "datetime"
746
+ ? "datetime-local"
747
+ : ["date", "time", "color", "number", "email", "password", "url", "tel"].includes(f._type)
748
+ ? f._type
749
+ : "text";
750
+ return <input type={type} value={form[f._key]} placeholder={f._placeholder} class={cls} />;
751
+ }
752
+
753
+ // ── Query builder ──────────────────────────────────────────────────────────
754
+
755
+ /** One rule row: which field, how it compares, and what to. */
756
+ private _builderRule(
757
+ filter: Filter,
758
+ rule: Extract<QueryRule, { type: "rule" }>,
759
+ path: string,
760
+ index: number,
761
+ ): HtmlNode {
762
+ const key = filter._key;
763
+ const constraint = filter._constraints.find((c) => c._key === rule.constraint);
764
+ const control =
765
+ "h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground outline-none focus:ring-2 focus:ring-ring";
766
+ const unary = constraint?.isUnary(rule.operator) ?? false;
767
+
768
+ return (
769
+ <div class="flex flex-wrap items-center gap-1.5">
770
+ <select
771
+ onChange={this.setBuilderRule}
772
+ data-args={JSON.stringify([key, path, index, "constraint"])}
773
+ class={control}
774
+ >
775
+ {filter._constraints.map((c) => (
776
+ <option value={c._key} selected={c._key === rule.constraint}>
777
+ {c.getLabel()}
778
+ </option>
779
+ ))}
780
+ </select>
781
+
782
+ <select
783
+ onChange={this.setBuilderRule}
784
+ data-args={JSON.stringify([key, path, index, "operator"])}
785
+ class={control}
786
+ >
787
+ {(constraint?.operators() ?? []).map((o) => (
788
+ <option value={o.value} selected={o.value === rule.operator}>
789
+ {o.label}
790
+ </option>
791
+ ))}
792
+ </select>
793
+
794
+ {/* A unary operator ("is empty") needs no value, so none is offered. */}
795
+ {unary ? null : constraint?._kind === "select" ? (
796
+ <select
797
+ onChange={this.setBuilderRule}
798
+ data-args={JSON.stringify([key, path, index, "value"])}
799
+ class={control}
800
+ >
801
+ <option value="">Choose…</option>
802
+ {constraint._options.map((o) => (
803
+ <option value={o.value} selected={o.value === rule.value}>
804
+ {o.label}
805
+ </option>
806
+ ))}
807
+ </select>
808
+ ) : (
809
+ <input
810
+ type={
811
+ constraint?._kind === "number"
812
+ ? "number"
813
+ : constraint?._kind === "date"
814
+ ? "date"
815
+ : "text"
816
+ }
817
+ value={rule.value ?? ""}
818
+ onChange={this.setBuilderRule}
819
+ data-args={JSON.stringify([key, path, index, "value"])}
820
+ placeholder="value"
821
+ class={`${control} w-40`}
822
+ />
823
+ )}
824
+
825
+ <button
826
+ type="button"
827
+ onClick={this.removeBuilderRule}
828
+ data-args={JSON.stringify([key, path, index])}
829
+ title="Remove"
830
+ class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
831
+ >
832
+ <Icon name="x-circle" class="h-4 w-4" />
833
+ </button>
834
+ </div>
835
+ );
836
+ }
837
+
838
+ /**
839
+ * One group: its rules, its nested groups, and the AND/OR toggle that joins
840
+ * them. Rendered recursively, so nesting depth is whatever the user built.
841
+ */
842
+ private _builderGroup(
843
+ filter: Filter,
844
+ group: Extract<QueryRule, { type: "group" }>,
845
+ path: string,
846
+ depth: number,
847
+ ): HtmlNode {
848
+ const key = filter._key;
849
+ const toggle = (op: "and" | "or"): string =>
850
+ `rounded px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide transition ${
851
+ group.operator === op
852
+ ? "bg-primary text-primary-foreground"
853
+ : "text-muted-foreground hover:bg-accent hover:text-foreground"
854
+ }`;
855
+
856
+ return (
857
+ <div
858
+ class={
859
+ depth === 0
860
+ ? "space-y-2"
861
+ : "space-y-2 rounded-lg border border-dashed border-border bg-background/60 p-2.5"
862
+ }
863
+ >
864
+ {group.rules.map((rule, index) => (
865
+ <div class="flex items-start gap-2">
866
+ {/* The joiner reads down the left edge: the first row has nothing
867
+ before it to combine with, so it shows "Where" instead. */}
868
+ <div class="flex w-14 shrink-0 items-center pt-1">
869
+ {index === 0 ? (
870
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
871
+ Where
872
+ </span>
873
+ ) : index === 1 ? (
874
+ <div class="flex rounded-md border border-border">
875
+ <button
876
+ type="button"
877
+ onClick={this.setBuilderGroupOperator}
878
+ data-args={JSON.stringify([key, path, "and"])}
879
+ class={toggle("and")}
880
+ >
881
+ and
882
+ </button>
883
+ <button
884
+ type="button"
885
+ onClick={this.setBuilderGroupOperator}
886
+ data-args={JSON.stringify([key, path, "or"])}
887
+ class={toggle("or")}
888
+ >
889
+ or
890
+ </button>
891
+ </div>
892
+ ) : (
893
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
894
+ {group.operator}
895
+ </span>
896
+ )}
897
+ </div>
898
+
899
+ <div class="min-w-0 flex-1">
900
+ {rule.type === "group" ? (
901
+ <div class="flex items-start gap-2">
902
+ <div class="min-w-0 flex-1">
903
+ {this._builderGroup(
904
+ filter,
905
+ rule,
906
+ path ? `${path}.${index}` : String(index),
907
+ depth + 1,
908
+ )}
909
+ </div>
910
+ <button
911
+ type="button"
912
+ onClick={this.removeBuilderRule}
913
+ data-args={JSON.stringify([key, path, index])}
914
+ title="Remove group"
915
+ class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
916
+ >
917
+ <Icon name="x-circle" class="h-4 w-4" />
918
+ </button>
919
+ </div>
920
+ ) : (
921
+ this._builderRule(filter, rule, path, index)
922
+ )}
923
+ </div>
924
+ </div>
925
+ ))}
926
+
927
+ <div class="flex items-center gap-2 pl-16">
928
+ <button
929
+ type="button"
930
+ onClick={this.addBuilderRule}
931
+ data-args={JSON.stringify([key, path])}
932
+ class="inline-flex h-7 items-center gap-1 rounded-md border border-input bg-background px-2 text-xs font-medium transition hover:bg-accent hover:text-accent-foreground"
933
+ >
934
+ <Icon name="plus" class="h-3.5 w-3.5" /> Rule
935
+ </button>
936
+ <button
937
+ type="button"
938
+ onClick={this.addBuilderGroup}
939
+ data-args={JSON.stringify([key, path])}
940
+ class="inline-flex h-7 items-center gap-1 rounded-md border border-input bg-background px-2 text-xs font-medium transition hover:bg-accent hover:text-accent-foreground"
941
+ >
942
+ <Icon name="plus" class="h-3.5 w-3.5" /> Group
943
+ </button>
944
+ </div>
945
+ </div>
946
+ );
947
+ }
948
+
949
+ /** The whole query-builder card for one filter. */
950
+ private _queryBuilder(filter: Filter, applied: boolean): HtmlNode {
951
+ const draft = this._draft(filter._key);
952
+ return (
953
+ <div class="rounded-lg border border-border bg-card">
954
+ <div class="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
955
+ <span class="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
956
+ <Icon name="filter" class="h-4 w-4" />
957
+ {filter.getLabel()}
958
+ {applied ? (
959
+ <span class="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
960
+ active
961
+ </span>
962
+ ) : null}
963
+ </span>
964
+ <div class="flex items-center gap-2">
965
+ {applied || draft.rules.length > 0 ? (
966
+ <button
967
+ type="button"
968
+ onClick={this.clearBuilder}
969
+ data-args={JSON.stringify([filter._key])}
970
+ class="inline-flex h-8 items-center rounded-lg border border-input bg-background px-3 text-xs font-medium transition hover:bg-accent hover:text-accent-foreground"
971
+ >
972
+ Clear
973
+ </button>
974
+ ) : null}
975
+ <button
976
+ type="button"
977
+ onClick={this.applyBuilder}
978
+ data-args={JSON.stringify([filter._key])}
979
+ class="inline-flex h-8 items-center rounded-lg bg-primary px-3 text-xs font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90"
980
+ >
981
+ Apply
982
+ </button>
983
+ </div>
984
+ </div>
985
+ <div class="p-3">
986
+ {draft.rules.length === 0 ? (
987
+ <button
988
+ type="button"
989
+ onClick={this.addBuilderRule}
990
+ data-args={JSON.stringify([filter._key, ""])}
991
+ class="inline-flex h-8 items-center gap-1 rounded-md border border-dashed border-border px-3 text-xs font-medium text-muted-foreground transition hover:bg-accent hover:text-foreground"
992
+ >
993
+ <Icon name="plus" class="h-3.5 w-3.5" /> Add a rule
994
+ </button>
995
+ ) : (
996
+ this._builderGroup(filter, draft, "", 0)
997
+ )}
998
+ </div>
999
+ </div>
1000
+ );
1001
+ }
1002
+
1003
+ /**
1004
+ * What to show in place of the table.
1005
+ *
1006
+ * A narrowed-down view and a genuinely empty resource are different problems:
1007
+ * the first wants "widen your search", the second wants the resource's own
1008
+ * {@link Resource.emptyState}, which can explain what will fill it.
1009
+ */
1010
+ private _emptyState(): HtmlNode {
1011
+ const R = this._resource;
1012
+ const narrowed = Boolean(this.search || this.filters || this.tab || this.trashed);
1013
+
1014
+ const heading = narrowed ? "No matches" : R.emptyState().heading;
1015
+ const description = narrowed
1016
+ ? "Try a different search or clear the filters."
1017
+ : R.emptyState().description;
1018
+ const icon = narrowed ? "search" : (R.emptyState().icon ?? "inbox");
1019
+ const actions = narrowed ? [] : (R.emptyState().actions ?? []);
1020
+ const ctx = this._ctxBase();
1021
+
1022
+ return (
1023
+ <Empty
1024
+ // Bare: the table already draws the border this would sit inside.
1025
+ bare
1026
+ class="py-16"
1027
+ icon={
1028
+ <span class="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
1029
+ <Icon name={icon} class="h-6 w-6" />
1030
+ </span>
1031
+ }
1032
+ title={heading}
1033
+ {...(description ? { description } : {})}
1034
+ {...(actions.length > 0
1035
+ ? {
1036
+ action: actions.map((a) =>
1037
+ a instanceof ActionGroup
1038
+ ? renderActionGroup(a, ctx, {
1039
+ onRun: this.runAction,
1040
+ argsFor: (member) => [member._key, ""],
1041
+ })
1042
+ : renderAction(a, ctx, {
1043
+ onRun: this.runAction,
1044
+ onForm: this.openActionForm,
1045
+ args: [a._key, ""],
1046
+ }),
1047
+ ),
1048
+ }
1049
+ : {})}
1050
+ />
1051
+ );
1052
+ }
1053
+
1054
+ // ── Saved views ────────────────────────────────────────────────────────────
1055
+
1056
+ /** Persist the list's current shape under a name. */
1057
+ @expose async saveCurrentView(): Promise<void> {
1058
+ const provider = this._panel.savedViewProvider();
1059
+ const name = this.newViewName.trim();
1060
+ if (!provider || !name) {
1061
+ this.flash("Give the view a name first.", "warning");
1062
+ return;
1063
+ }
1064
+ await provider.save({
1065
+ resource: this._resource.getSlug(),
1066
+ name,
1067
+ // Only the shape, not the page — a saved view always opens at the top.
1068
+ query: viewQuery(this._params({ page: undefined })),
1069
+ });
1070
+ this.newViewName = "";
1071
+ this.flash(`Saved "${name}".`);
1072
+ }
1073
+
1074
+ @expose async deleteView(id: unknown): Promise<void> {
1075
+ const provider = this._panel.savedViewProvider();
1076
+ if (!provider) return;
1077
+ await provider.remove(String(id));
1078
+ this.flash("View deleted.");
1079
+ }
1080
+
1081
+ /** The saved-views control, or nothing when the app configured no provider. */
1082
+ private async _savedViews(): Promise<HtmlNode | null> {
1083
+ const provider = this._panel.savedViewProvider();
1084
+ if (!provider) return null;
1085
+
1086
+ const views = await provider.list(this._resource.getSlug());
1087
+ const current = this._params({ page: undefined });
1088
+
1089
+ return (
1090
+ <DropdownMenu
1091
+ align="right"
1092
+ trigger={
1093
+ <button
1094
+ type="button"
1095
+ class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-input bg-background px-3 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
1096
+ >
1097
+ <Icon name="eye" class="h-4 w-4" /> Views
1098
+ {views.length > 0 ? (
1099
+ <span class="rounded-full bg-muted px-1.5 text-[11px]">{views.length}</span>
1100
+ ) : null}
1101
+ </button>
1102
+ }
1103
+ >
1104
+ {views.length === 0 ? (
1105
+ <div class="px-2 py-1.5 text-xs text-muted-foreground">No saved views yet.</div>
1106
+ ) : (
1107
+ views.map((v) => (
1108
+ <div class="flex items-center gap-1">
1109
+ <a
1110
+ href={`?${v.query}`}
1111
+ navigate
1112
+ class={`flex flex-1 cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground ${
1113
+ viewIsActive(v, current) ? "font-semibold text-primary" : ""
1114
+ }`}
1115
+ >
1116
+ {v.name}
1117
+ {v.shared ? (
1118
+ <span class="ml-auto text-[10px] uppercase text-muted-foreground">shared</span>
1119
+ ) : null}
1120
+ </a>
1121
+ <button
1122
+ type="button"
1123
+ onClick={this.deleteView}
1124
+ data-args={JSON.stringify([v.id])}
1125
+ title="Delete this view"
1126
+ class="rounded p-1 text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
1127
+ >
1128
+ <Icon name="x-circle" class="h-3.5 w-3.5" />
1129
+ </button>
1130
+ </div>
1131
+ ))
1132
+ )}
1133
+
1134
+ <div class="-mx-1 my-1 h-px bg-border" />
1135
+ <form onSubmit={this.saveCurrentView} class="flex items-center gap-1 px-2 py-1.5">
1136
+ <input
1137
+ type="text"
1138
+ value={this.newViewName}
1139
+ placeholder="Save this view as…"
1140
+ class="h-7 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
1141
+ />
1142
+ <button
1143
+ type="submit"
1144
+ class="rounded bg-primary px-2 py-1 text-xs font-semibold text-primary-foreground transition hover:bg-primary/90"
1145
+ >
1146
+ Save
1147
+ </button>
1148
+ </form>
1149
+ </DropdownMenu>
1150
+ );
1151
+ }
1152
+
1153
+ // ── Filters ────────────────────────────────────────────────────────────────
1154
+
1155
+ /**
1156
+ * Chips naming every filter currently narrowing the list, each one its own
1157
+ * undo.
1158
+ *
1159
+ * A table showing four of two hundred rows with no visible reason is the most
1160
+ * common way an admin panel misleads someone. These say why, and let it be
1161
+ * undone without hunting for the control that caused it.
1162
+ */
1163
+ private _filterIndicators(filters: Filter[], active: Record<string, string>): HtmlNode | null {
1164
+ const chips: { label: string; href: string }[] = [];
1165
+
1166
+ if (this.search) {
1167
+ chips.push({
1168
+ label: `Search: ${this.search}`,
1169
+ href: "?" + this._params({ page: undefined, search: "" }).toString(),
1170
+ });
1171
+ }
1172
+
1173
+ for (const f of filters) {
1174
+ const value = active[f._key];
1175
+ if (value == null || value === "") continue;
1176
+ const shown =
1177
+ f._type === "builder"
1178
+ ? describeRuleTree(parseRuleTree(value), f)
1179
+ : (f.choices().find((c) => c.value === value)?.label ?? value);
1180
+ chips.push({ label: `${f.getLabel()}: ${shown}`, href: this._filterHref(f._key, "") });
1181
+ }
1182
+
1183
+ if (this.trashed) {
1184
+ chips.push({
1185
+ label: this.trashed === "only" ? "Trashed only" : "Including trashed",
1186
+ href: "?" + this._params({ page: undefined, trashed: "" }).toString(),
1187
+ });
1188
+ }
1189
+
1190
+ if (chips.length === 0) return null;
1191
+
1192
+ return (
1193
+ <div class="flex flex-wrap items-center gap-2">
1194
+ <span class="text-xs font-semibold text-muted-foreground">Filtered by</span>
1195
+ {chips.map((c) => (
1196
+ <a
1197
+ href={c.href}
1198
+ navigate
1199
+ title="Remove this filter"
1200
+ class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary transition hover:bg-primary/20"
1201
+ >
1202
+ {c.label}
1203
+ <Icon name="x-circle" class="h-3.5 w-3.5" />
1204
+ </a>
1205
+ ))}
1206
+ {chips.length > 1 ? (
1207
+ <a
1208
+ href={
1209
+ "?" +
1210
+ this._params({ page: undefined, search: "", filters: "", trashed: "" }).toString()
1211
+ }
1212
+ navigate
1213
+ class="text-xs font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
1214
+ >
1215
+ Clear all
1216
+ </a>
1217
+ ) : null}
1218
+ </div>
1219
+ );
1220
+ }
1221
+
1222
+ /** The filter controls themselves, placed per the resource's `filterLayout`. */
1223
+ private _filterBar(filters: Filter[], active: Record<string, string>): HtmlNode {
1224
+ const R = this._resource;
1225
+ const pill = (on: boolean): string =>
1226
+ `rounded-full px-2.5 py-1 text-xs font-medium transition ${
1227
+ on
1228
+ ? "bg-primary text-primary-foreground"
1229
+ : "text-muted-foreground hover:bg-accent hover:text-foreground"
1230
+ }`;
1231
+
1232
+ const controls = filters.map((f) => {
1233
+ const current = active[f._key] ?? "";
1234
+ return (
1235
+ <div class="flex items-center gap-1.5">
1236
+ <span class="text-xs font-semibold text-muted-foreground">{f.getLabel()}</span>
1237
+ <a href={this._filterHref(f._key, "")} navigate class={pill(current === "")}>
1238
+ All
1239
+ </a>
1240
+ {f.choices().map((o) => (
1241
+ <a href={this._filterHref(f._key, o.value)} navigate class={pill(current === o.value)}>
1242
+ {o.label}
1243
+ </a>
1244
+ ))}
1245
+ </div>
1246
+ );
1247
+ });
1248
+
1249
+ if (R.filterLayout === "inline") {
1250
+ return (
1251
+ <div class="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-lg border border-border bg-card px-3 py-2.5">
1252
+ {controls}
1253
+ </div>
1254
+ );
1255
+ }
1256
+
1257
+ // Panel and drawer both collapse behind a toggle; they differ only in where
1258
+ // the revealed controls sit. A native <details> keeps that a pure-CSS
1259
+ // affordance — no state to round-trip for opening a filter panel.
1260
+ const active_count = filters.filter((f) => (active[f._key] ?? "") !== "").length;
1261
+ return (
1262
+ <details class="group/filters rounded-lg border border-border bg-card">
1263
+ <summary class="flex cursor-pointer items-center gap-2 px-3 py-2.5 text-xs font-semibold text-muted-foreground [&::-webkit-details-marker]:hidden">
1264
+ <Icon name="filter" class="h-4 w-4" />
1265
+ Filters
1266
+ {active_count > 0 ? (
1267
+ <span class="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
1268
+ {active_count}
1269
+ </span>
1270
+ ) : null}
1271
+ <Icon
1272
+ name="chevron-down"
1273
+ class="ml-auto h-3.5 w-3.5 transition group-open/filters:rotate-180"
1274
+ />
1275
+ </summary>
1276
+ <div
1277
+ class={
1278
+ R.filterLayout === "drawer"
1279
+ ? "flex flex-col gap-3 border-t border-border px-3 py-3 sm:max-w-xs"
1280
+ : "flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-border px-3 py-3"
1281
+ }
1282
+ >
1283
+ {controls}
1284
+ </div>
1285
+ </details>
1286
+ );
1287
+ }
1288
+
1289
+ // ── Table presentation ─────────────────────────────────────────────────────
1290
+
1291
+ /** Row striping and density, applied as classes rather than table variants. */
1292
+ private _tableClass(): string | undefined {
1293
+ const R = this._resource;
1294
+ const parts: string[] = [];
1295
+ if (R.striped) parts.push("[&_tbody_tr:nth-child(even)]:bg-muted/40");
1296
+ if (R.density === "compact") parts.push("[&_td]:py-1.5 [&_th]:py-1.5 text-[13px]");
1297
+ return parts.length > 0 ? parts.join(" ") : undefined;
1298
+ }
1299
+
1300
+ /**
1301
+ * The grid layout: one card per record instead of a row.
1302
+ *
1303
+ * The first image column becomes the card's picture and the first text column
1304
+ * its title; the rest render as label/value pairs. That ordering falls out of
1305
+ * how columns are already declared, so a resource opts into the grid without
1306
+ * describing itself twice.
1307
+ */
1308
+ private _grid(cols: Column[], rows: Record<string, unknown>[], pk: string): HtmlNode {
1309
+ const image = cols.find((c) => c._kind === "image");
1310
+ const title = cols.find((c) => c !== image && c._kind === "text");
1311
+ const rest = cols.filter((c) => c !== image && c !== title).slice(0, 4);
1312
+
1313
+ return (
1314
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
1315
+ {rows.map((row) => {
1316
+ const ctx = this._ctx(row);
1317
+ const id = String(row[pk]);
1318
+ return (
1319
+ <div class="flex flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm transition hover:border-primary/40 hover:shadow-md">
1320
+ {image ? (
1321
+ <div class="aspect-[4/3] w-full overflow-hidden bg-muted">
1322
+ {this._cell(image, row)}
1323
+ </div>
1324
+ ) : null}
1325
+ <div class="flex flex-1 flex-col gap-2 p-4">
1326
+ <div class="text-sm font-semibold">{title ? this._cell(title, row) : id}</div>
1327
+ <dl class="space-y-1 text-xs">
1328
+ {rest.map((c) => (
1329
+ <div class="flex items-center justify-between gap-2">
1330
+ <dt class="text-muted-foreground">{c.getLabel()}</dt>
1331
+ <dd class="truncate">{this._cell(c, row)}</dd>
1332
+ </div>
1333
+ ))}
1334
+ </dl>
1335
+ <div class="mt-auto flex items-center justify-end gap-1 pt-2">
1336
+ {this._rowActions()
1337
+ .filter((a): a is Action => a instanceof Action)
1338
+ .filter((a) => a.isVisibleFor(row as AdminRecord, ctx))
1339
+ .slice(0, 3)
1340
+ .map((a) =>
1341
+ renderAction(a, ctx, {
1342
+ onRun: this.runAction,
1343
+ onForm: this.openActionForm,
1344
+ args: [a._key, id],
1345
+ }),
1346
+ )}
1347
+ </div>
1348
+ </div>
1349
+ </div>
1350
+ );
1351
+ })}
1352
+ </div>
1353
+ );
1354
+ }
1355
+
1356
+ /**
1357
+ * A kanban board: one lane per value of the resource's `kanbanColumn`.
1358
+ *
1359
+ * Moving a card is a server action rather than a drag. The lane a record sits
1360
+ * in is a field on it, and setting a field is something the panel already
1361
+ * knows how to authorise; dragging would look nicer and would need its own
1362
+ * permission story to be equally safe.
1363
+ */
1364
+ private _kanban(cols: Column[], rows: Record<string, unknown>[], pk: string): HtmlNode {
1365
+ const R = this._resource;
1366
+ const column = R.kanbanColumn!;
1367
+ const declared = Object.keys(R.kanbanLanes);
1368
+ // Declared lanes first, then any value actually present that wasn't
1369
+ // declared — a board that silently hides records is worse than an untidy one.
1370
+ const present = [...new Set(rows.map((r) => String(r[column] ?? "")))];
1371
+ const lanes = [...declared, ...present.filter((v) => !declared.includes(v))];
1372
+
1373
+ const title = cols.find((c) => c._kind === "text");
1374
+ const rest = cols.filter((c) => c !== title && c._kind !== "image").slice(0, 3);
1375
+ const base = this._panel.base();
1376
+ const parent = this.parentId || undefined;
1377
+
1378
+ return (
1379
+ <div class="flex gap-3 overflow-x-auto pb-2">
1380
+ {lanes.map((lane, laneIndex) => {
1381
+ const inLane = rows.filter((r) => String(r[column] ?? "") === lane);
1382
+ const prev = lanes[laneIndex - 1];
1383
+ const next = lanes[laneIndex + 1];
1384
+ return (
1385
+ <div class="flex w-72 shrink-0 flex-col rounded-xl border border-border bg-muted/30">
1386
+ <div class="flex items-center gap-2 border-b border-border px-3 py-2">
1387
+ <span class="text-sm font-semibold">{R.kanbanLanes[lane] ?? lane ?? "—"}</span>
1388
+ <span class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">
1389
+ {inLane.length}
1390
+ </span>
1391
+ </div>
1392
+ <div class="flex flex-col gap-2 p-2">
1393
+ {inLane.map((row) => {
1394
+ const id = String(row[pk]);
1395
+ return (
1396
+ <div class="rounded-lg border border-border bg-card p-3 text-card-foreground shadow-sm">
1397
+ <a
1398
+ href={R.recordUrl(base, id, parent)}
1399
+ navigate
1400
+ class="block text-sm font-medium hover:underline"
1401
+ >
1402
+ {title ? this._cell(title, row) : id}
1403
+ </a>
1404
+ <dl class="mt-1.5 space-y-0.5 text-xs text-muted-foreground">
1405
+ {rest.map((c) => (
1406
+ <div class="flex items-center justify-between gap-2">
1407
+ <dt>{c.getLabel()}</dt>
1408
+ <dd class="truncate text-foreground">{this._cell(c, row)}</dd>
1409
+ </div>
1410
+ ))}
1411
+ </dl>
1412
+ <div class="mt-2 flex items-center gap-1">
1413
+ {prev !== undefined ? (
1414
+ <button
1415
+ type="button"
1416
+ onClick={this.moveToLane}
1417
+ data-args={JSON.stringify([id, prev])}
1418
+ title={`Move to ${R.kanbanLanes[prev] ?? prev}`}
1419
+ class="rounded border border-input px-1.5 py-0.5 text-xs text-muted-foreground transition hover:bg-accent hover:text-foreground"
1420
+ >
1421
+
1422
+ </button>
1423
+ ) : null}
1424
+ {next !== undefined ? (
1425
+ <button
1426
+ type="button"
1427
+ onClick={this.moveToLane}
1428
+ data-args={JSON.stringify([id, next])}
1429
+ title={`Move to ${R.kanbanLanes[next] ?? next}`}
1430
+ class="rounded border border-input px-1.5 py-0.5 text-xs text-muted-foreground transition hover:bg-accent hover:text-foreground"
1431
+ >
1432
+
1433
+ </button>
1434
+ ) : null}
1435
+ </div>
1436
+ </div>
1437
+ );
1438
+ })}
1439
+ {inLane.length === 0 ? (
1440
+ <p class="px-2 py-6 text-center text-xs text-muted-foreground">Nothing here.</p>
1441
+ ) : null}
1442
+ </div>
1443
+ </div>
1444
+ );
1445
+ })}
1446
+ </div>
1447
+ );
1448
+ }
1449
+
1450
+ /**
1451
+ * A month grid with each record on its date.
1452
+ *
1453
+ * Anchored on the month the listed rows actually fall in rather than on today,
1454
+ * so a calendar opened from a filtered list lands where the data is instead of
1455
+ * on an empty current month.
1456
+ */
1457
+ private _calendar(cols: Column[], rows: Record<string, unknown>[], pk: string): HtmlNode {
1458
+ const R = this._resource;
1459
+ const column = R.calendarColumn!;
1460
+ const title = cols.find((c) => c._kind === "text");
1461
+ const base = this._panel.base();
1462
+ const parent = this.parentId || undefined;
1463
+
1464
+ /**
1465
+ * The calendar day a row falls on, as `YYYY-MM-DD`.
1466
+ *
1467
+ * A date-only column arrives as a string already and is used as-is: parsing
1468
+ * it into a Date and formatting it back is what moves a record onto the
1469
+ * previous day for anyone west of UTC.
1470
+ */
1471
+ const dayOf = (row: Record<string, unknown>): string | null => {
1472
+ const raw = row[column];
1473
+ if (!raw) return null;
1474
+ if (typeof raw === "string" && /^\d{4}-\d{2}-\d{2}/.test(raw)) return raw.slice(0, 10);
1475
+ const date = raw instanceof Date ? raw : new Date(String(raw));
1476
+ return Number.isNaN(date.getTime()) ? null : isoDay(date);
1477
+ };
1478
+
1479
+ const events = rows
1480
+ .map((row) => ({ row, day: dayOf(row) }))
1481
+ .filter((x): x is { row: Record<string, unknown>; day: string } => x.day !== null)
1482
+ .map(({ row, day }) => ({
1483
+ date: day,
1484
+ label: title ? title.cell(row).text : String(row[pk]),
1485
+ href: R.recordUrl(base, String(row[pk]), parent),
1486
+ }));
1487
+
1488
+ // Anchored on the month the listed rows fall in rather than today, so paging
1489
+ // back through older records does not land on an empty grid.
1490
+ const month = events[0]?.date.slice(0, 7) ?? isoDay(new Date()).slice(0, 7);
1491
+
1492
+ return <Calendar month={month} events={events} />;
1493
+ }
1494
+ /**
1495
+ * Move a record to another kanban lane.
1496
+ *
1497
+ * The lane change is the same authorised update a row action would make —
1498
+ * dragging between columns is a second way to reach one behaviour, not a
1499
+ * second path that skips the check.
1500
+ */
1501
+ @expose async moveToLane(id: unknown, lane: unknown): Promise<void> {
1502
+ const R = this._resource;
1503
+ const column = R.kanbanColumn;
1504
+ if (!column) return;
1505
+
1506
+ const record = await R.find(String(id));
1507
+ assertCan(R, "update", record ?? undefined);
1508
+ await R.update(String(id), { [column]: String(lane) });
1509
+ }
1510
+
1511
+ private _actionModal(): HtmlNode {
1512
+ const act = this._resolveFormAction(this.actionFormKey);
1513
+ const fields = act?.fieldsFor(this.actionForm, this._resource) ?? [];
1514
+ const needsFileReader = fields.some((f) => f._type === "file");
1515
+ return (
1516
+ <Dialog show={this.actionModalOpen} title={act?._modalHeading ?? act?.getLabel() ?? "Action"}>
1517
+ {needsFileReader ? (
1518
+ <script dangerouslySetInnerHTML={{ __html: FILE_READER_SCRIPT }} />
1519
+ ) : null}
1520
+ <form onSubmit={this.submitActionForm} class="space-y-4">
1521
+ {fields.map((f) => (
1522
+ <div>
1523
+ <label class="block text-sm font-medium text-foreground">
1524
+ {f.getLabel()}
1525
+ {f._required ? <span class="ml-0.5 text-destructive">*</span> : null}
1526
+ </label>
1527
+ {this._modalControl(f)}
1528
+ {this.actionErrors[f._key] ? (
1529
+ <p class="mt-1 text-xs text-destructive">{this.actionErrors[f._key]}</p>
1530
+ ) : f._helper ? (
1531
+ <p class="mt-1 text-xs text-muted-foreground">{f._helper}</p>
1532
+ ) : null}
1533
+ </div>
1534
+ ))}
1535
+ <div class="flex items-center justify-end gap-2 pt-2">
1536
+ <button
1537
+ type="button"
1538
+ onClick={this.closeActionForm}
1539
+ class="inline-flex h-9 items-center rounded-lg border border-input bg-background px-4 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
1540
+ >
1541
+ Cancel
1542
+ </button>
1543
+ <button
1544
+ type="submit"
1545
+ loadingAttr="disabled"
1546
+ class="inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-60"
1547
+ >
1548
+ {act?._modalSubmit ?? act?.getLabel() ?? "Submit"}
1549
+ </button>
1550
+ </div>
1551
+ </form>
1552
+ </Dialog>
1553
+ );
1554
+ }
1555
+
1556
+ /**
1557
+ * Reactive row action: delete a record and flash the result. Flow re-renders
1558
+ * the component after the action, and render() re-queries the records — so the
1559
+ * deleted row (and the total count) drop out of the table automatically.
1560
+ */
1561
+ @expose async deleteRecord(id: unknown): Promise<void> {
1562
+ const R = this._resource;
1563
+ // This bypasses Action entirely, so it carries its own gate.
1564
+ const record = (await R.find(id)) as Record<string, unknown> | null;
1565
+ assertCan(R, "delete", record ?? undefined);
1566
+ if (await R.destroy(id)) this.flash(`${R.getLabel()} deleted.`);
1567
+ else this.flash("That record no longer exists.", "warning");
1568
+ }
1569
+
1570
+ /** The locale the list is currently showing, for a translatable resource. */
1571
+ private _locale(): string {
1572
+ const R = this._resource;
1573
+ return this.locale || R.locales[0] || "en";
1574
+ }
1575
+
1576
+ private _cell(col: Column, row: Record<string, unknown>): HtmlNode | string {
1577
+ const R = this._resource;
1578
+ // A translatable column stores every locale; the table shows one. Resolved
1579
+ // here so every cell kind — badge, custom renderer, plain text — sees the
1580
+ // value for the active locale rather than the whole map.
1581
+ if (R.translatable.includes(col._key)) {
1582
+ row = { ...row, [col._key]: R.translated(row[col._key], this._locale()) };
1583
+ }
1584
+
1585
+ // A custom renderer takes the cell outright. Checked first so it can replace
1586
+ // any built-in kind, not just sit alongside them.
1587
+ if (col._render) return col._render(col.raw(row), row);
1588
+
1589
+ // Inline toggle — flips the boolean on the record via a server action.
1590
+ if (col._kind === "toggle") {
1591
+ const on = !!col.raw(row);
1592
+ const id = String(row[this._resource.primaryKey]);
1593
+ return (
1594
+ <button
1595
+ type="button"
1596
+ role="switch"
1597
+ onClick={this.toggleColumn}
1598
+ data-args={JSON.stringify([id, col.getColumn(), !on])}
1599
+ class={`relative inline-flex h-5 w-9 items-center rounded-full transition ${on ? "bg-primary" : "bg-input"}`}
1600
+ >
1601
+ <span
1602
+ class={`inline-block h-4 w-4 rounded-full bg-background shadow transition ${on ? "translate-x-4" : "translate-x-0.5"}`}
1603
+ />
1604
+ </button>
1605
+ );
1606
+ }
1607
+
1608
+ // Inline select — saves the chosen value on change (model syncs first).
1609
+ if (col._kind === "select") {
1610
+ const id = String(row[this._resource.primaryKey]);
1611
+ const k = `${id}__${col._key}`;
1612
+ if (!(k in this.cellEdits)) this.cellEdits[k] = row[col._key];
1613
+ const cur = String(this.cellEdits[k] ?? "");
1614
+ return (
1615
+ <select
1616
+ value={this.cellEdits[k]}
1617
+ onChange={this.saveCell}
1618
+ data-args={JSON.stringify([id, col.getColumn(), k])}
1619
+ class="h-8 rounded-md border border-input bg-background px-2 text-sm outline-none transition focus:ring-2 focus:ring-ring"
1620
+ >
1621
+ {(col._options ?? []).map((o) => (
1622
+ <option value={o.value} selected={String(o.value) === cur}>
1623
+ {o.label}
1624
+ </option>
1625
+ ))}
1626
+ </select>
1627
+ );
1628
+ }
1629
+
1630
+ // Inline text input — saves on change/blur.
1631
+ if (col._kind === "input") {
1632
+ const id = String(row[this._resource.primaryKey]);
1633
+ const k = `${id}__${col._key}`;
1634
+ if (!(k in this.cellEdits)) this.cellEdits[k] = row[col._key];
1635
+ return (
1636
+ <input
1637
+ type={col._inputType}
1638
+ value={this.cellEdits[k]}
1639
+ onChange={this.saveCell}
1640
+ data-args={JSON.stringify([id, col.getColumn(), k])}
1641
+ class="h-8 w-full max-w-[12rem] rounded-md border border-input bg-background px-2 text-sm outline-none transition focus:ring-2 focus:ring-ring"
1642
+ />
1643
+ );
1644
+ }
1645
+
1646
+ if (col._kind === "image") {
1647
+ // Resolved, not printed: a bare `media/x.jpg` is a disk path, and a
1648
+ // browser reads it relative to the page.
1649
+ const src = resolveMediaSrc(col.raw(row), this._panel.mediaDisk());
1650
+ return src ? (
1651
+ <img
1652
+ src={src}
1653
+ alt=""
1654
+ class={`h-9 w-9 object-cover ${col._circular ? "rounded-full" : "rounded-md"} border border-border`}
1655
+ />
1656
+ ) : (
1657
+ <span
1658
+ class={`flex h-9 w-9 items-center justify-center bg-muted text-muted-foreground ${col._circular ? "rounded-full" : "rounded-md"}`}
1659
+ >
1660
+ <Icon name="document" class="h-4 w-4" />
1661
+ </span>
1662
+ );
1663
+ }
1664
+
1665
+ if (col._kind === "color") {
1666
+ const v = col.raw(row);
1667
+ return (
1668
+ <span class="inline-flex items-center gap-2">
1669
+ <span
1670
+ class="h-4 w-4 rounded border border-border"
1671
+ style={`background:${v ? String(v) : "transparent"}`}
1672
+ />
1673
+ <span class="text-xs text-muted-foreground">{v ? String(v) : "—"}</span>
1674
+ </span>
1675
+ );
1676
+ }
1677
+
1678
+ if (col._kind === "icon") {
1679
+ const on = !!col.raw(row);
1680
+ return (
1681
+ <Icon
1682
+ name={on ? "check-circle" : "x-circle"}
1683
+ class={`h-5 w-5 ${on ? "text-success" : "text-muted-foreground/60"}`}
1684
+ />
1685
+ );
1686
+ }
1687
+
1688
+ const { text, badge } = col.cell(row);
1689
+ if (badge) {
1690
+ return (
1691
+ <span
1692
+ class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${BADGE_CLASS[badge]}`}
1693
+ >
1694
+ {text}
1695
+ </span>
1696
+ );
1697
+ }
1698
+ if (col._copyable) {
1699
+ return (
1700
+ <span class="inline-flex items-center gap-1">
1701
+ {text}
1702
+ <button
1703
+ type="button"
1704
+ onclick={`navigator.clipboard.writeText(${JSON.stringify(text)})`}
1705
+ title="Copy"
1706
+ class="text-muted-foreground transition hover:text-foreground"
1707
+ >
1708
+ <Icon name="copy" class="h-3.5 w-3.5" />
1709
+ </button>
1710
+ </span>
1711
+ );
1712
+ }
1713
+ return text;
1714
+ }
1715
+
1716
+ private _params(extra: Record<string, string | number | undefined> = {}): URLSearchParams {
1717
+ const sp = new URLSearchParams();
1718
+ if (this.tab) sp.set("tab", this.tab);
1719
+ if (this.search) sp.set("search", this.search);
1720
+ if (this.sortBy) {
1721
+ sp.set("sortBy", this.sortBy);
1722
+ sp.set("sortDir", this.sortDir);
1723
+ }
1724
+ if (this.filters) sp.set("filters", this.filters);
1725
+ if (this.trashed) sp.set("trashed", this.trashed);
1726
+ if (this.perPage) sp.set("perPage", this.perPage);
1727
+ if (this.cols) sp.set("cols", this.cols);
1728
+ if (this.group) sp.set("group", this.group);
1729
+ if (this.sort) sp.set("sort", this.sort);
1730
+ for (const [k, v] of Object.entries(extra)) {
1731
+ if (v === undefined || v === "") sp.delete(k);
1732
+ else sp.set(k, String(v));
1733
+ }
1734
+ return sp;
1735
+ }
1736
+
1737
+ /** Build a URL that sets/clears one filter (resets to page 1, keeps other state). */
1738
+ /**
1739
+ * Set one header filter from its control in the table header.
1740
+ *
1741
+ * Writes into the same `?filters=` map the filter bar uses, so a header filter
1742
+ * survives navigation, lands in a saved view, and shows up in the active-filter
1743
+ * chips like any other. Paging resets, because the row someone is filtering for
1744
+ * is unlikely to be on the page they were already on.
1745
+ */
1746
+ @expose setHeaderFilter(key: unknown, value: unknown): void {
1747
+ const map = parseFilters(this.filters);
1748
+ const raw = String(value ?? "");
1749
+ if (raw === "") delete map[String(key)];
1750
+ else map[String(key)] = raw;
1751
+ this.filters = Object.keys(map).length ? JSON.stringify(map) : "";
1752
+ this.page = "1";
1753
+ }
1754
+
1755
+ /** One control per column, aligned with the header cells above them. */
1756
+ private _headerFilterCells(cols: Column[]): unknown[] {
1757
+ const active = parseFilters(this.filters);
1758
+ const byColumn = new Map(this._headerFilters().map((f) => [f._column ?? f._key, f]));
1759
+ const control =
1760
+ "h-7 w-full rounded-md border border-input bg-background px-2 text-xs text-foreground outline-none focus:ring-2 focus:ring-ring";
1761
+
1762
+ return cols.map((c) => {
1763
+ const filter = byColumn.get(c._key);
1764
+ if (!filter) return null;
1765
+ const current = active[filter._key] ?? "";
1766
+
1767
+ if (filter._type === "text") {
1768
+ return (
1769
+ <input
1770
+ onChange={this.setHeaderFilter}
1771
+ data-args={JSON.stringify([filter._key])}
1772
+ value={current}
1773
+ placeholder="Filter…"
1774
+ aria-label={`Filter by ${c.getLabel()}`}
1775
+ class={control}
1776
+ />
1777
+ );
1778
+ }
1779
+
1780
+ return (
1781
+ <select
1782
+ onChange={this.setHeaderFilter}
1783
+ data-args={JSON.stringify([filter._key])}
1784
+ aria-label={`Filter by ${c.getLabel()}`}
1785
+ class={control}
1786
+ >
1787
+ <option value="">All</option>
1788
+ {filter.choices().map((o) => (
1789
+ <option value={o.value} selected={o.value === current}>
1790
+ {o.label}
1791
+ </option>
1792
+ ))}
1793
+ </select>
1794
+ );
1795
+ });
1796
+ }
1797
+
1798
+ private _filterHref(key: string, value: string): string {
1799
+ return "?" + new URLSearchParams(this._filterParams(key, value)).toString();
1800
+ }
1801
+
1802
+ /** The full query-string state with one filter set to `value` (empty clears it). */
1803
+ private _filterParams(key: string, value: string): Record<string, string> {
1804
+ const map = parseFilters(this.filters);
1805
+ if (value === "") delete map[key];
1806
+ else map[key] = value;
1807
+ const sp = this._params({ page: undefined });
1808
+ const enc = Object.keys(map).length ? JSON.stringify(map) : "";
1809
+ if (enc) sp.set("filters", enc);
1810
+ else sp.delete("filters");
1811
+ return Object.fromEntries(sp.entries());
1812
+ }
1813
+
1814
+ private _pageHref(n: number): string {
1815
+ return "?" + this._params({ page: n }).toString();
1816
+ }
1817
+
1818
+ /** Tab link — switches the active tab and resets to page 1. */
1819
+ private _tabHref(key: string): string {
1820
+ const sp = this._params({ page: undefined });
1821
+ sp.set("tab", key);
1822
+ return "?" + sp.toString();
1823
+ }
1824
+
1825
+ override async render(): Promise<HtmlNode> {
1826
+ const R = this._resource;
1827
+ const base = this._panel.base();
1828
+ const currentPage = Math.max(1, parseInt(this.page, 10) || 1);
1829
+
1830
+ // Resolve the active tab (defaults to the first) and its query scope.
1831
+ const tabs = R.tabs();
1832
+ const activeKey = this.tab || tabs[0]?._key || "";
1833
+ const activeTab = tabs.find((t) => t._key === activeKey);
1834
+
1835
+ // Row grouping + reordering scope.
1836
+ const groups = R.groups();
1837
+ const groupKey = this.group || R.defaultGroup || "";
1838
+ const activeGroup = groupKey ? groups.find((g) => g.getColumn() === groupKey) : undefined;
1839
+ const reorderCol = R.reorderable;
1840
+
1841
+ // Effective sort: a reorderable table defaults to its position column; the URL
1842
+ // sort overrides; an active grouping orders by its column so groups stay
1843
+ // contiguous across the page.
1844
+ let sortBy = this.sortBy || (reorderCol ?? R.defaultSort?.column) || "";
1845
+ let sortDir: "asc" | "desc" = this.sortBy
1846
+ ? this.sortDir
1847
+ : reorderCol
1848
+ ? "asc"
1849
+ : (R.defaultSort?.direction ?? "asc");
1850
+ if (activeGroup) {
1851
+ sortBy = activeGroup.getColumn();
1852
+ sortDir = "asc";
1853
+ }
1854
+ // The header link sorts by a column *key*; translate it to the DB column for the
1855
+ // query (a camelCase key may map to a snake_case column via `.column()`).
1856
+ const querySortBy = sortBy
1857
+ ? (R.columns()
1858
+ .find((c) => c._key === sortBy)
1859
+ ?.getColumn() ?? sortBy)
1860
+ : "";
1861
+
1862
+ // Active filters compose with the active tab to scope the query.
1863
+ const declaredFilters = R.filters();
1864
+ const headerFilters = this._headerFilters();
1865
+ const resourceFilters = [...declaredFilters, ...headerFilters];
1866
+ const active = parseFilters(this.filters);
1867
+ const modifyQuery = (q: AdminQuery): AdminQuery => {
1868
+ // The parent scope goes on first: a nested resource must never widen past
1869
+ // its parent, whatever a tab or filter asks for.
1870
+ q = this._scopeToParent(q);
1871
+ if (activeTab?._modify) q = activeTab._modify(q);
1872
+ for (const f of resourceFilters) {
1873
+ const v = active[f._key];
1874
+ if (v != null && v !== "") q = f.apply(q, v);
1875
+ }
1876
+ return q;
1877
+ };
1878
+
1879
+ // Soft-delete scope (only when the model supports it).
1880
+ const trashedMode: "with" | "only" | undefined =
1881
+ R.usesSoftDeletes() && (this.trashed === "with" || this.trashed === "only")
1882
+ ? this.trashed
1883
+ : undefined;
1884
+
1885
+ // The resource's widgets, rendered above the table. Prefixed so their canvas
1886
+ // ids can't collide with a dashboard's.
1887
+ const savedViews = await this._savedViews();
1888
+ const resourceWidgets = R.widgets();
1889
+ const widgetBlock = await renderWidgets(resourceWidgets, `${R.getSlug()}-chart`);
1890
+ const widgetPoll = widgetPollInterval(resourceWidgets);
1891
+
1892
+ // The parent's own title, so a nested list's trail reads "Posts / Hello world /
1893
+ // Comments" rather than showing a bare id.
1894
+ const parentTitle =
1895
+ R.parent && this.parentId
1896
+ ? await R.parentResource()!
1897
+ .find(this.parentId)
1898
+ .then((rec) => (rec ? R.parentResource()!.recordTitle(rec) : null))
1899
+ .catch(() => null)
1900
+ : null;
1901
+
1902
+ // Secondary sorts sit beneath the header's own, applied in the order given.
1903
+ const extraSorts = this.sort
1904
+ .split(",")
1905
+ .map((part) => part.split(":"))
1906
+ .filter((pair): pair is [string, string] => Boolean(pair[0]))
1907
+ .map(([col, dir]) => ({ column: col!, direction: dir === "desc" ? "desc" : "asc" }) as const);
1908
+
1909
+ const result = await R.records({
1910
+ page: currentPage,
1911
+ perPage: Math.max(1, parseInt(this.perPage, 10) || R.perPage),
1912
+ search: this.search || undefined,
1913
+ sortBy: querySortBy || undefined,
1914
+ sortDir,
1915
+ modifyQuery,
1916
+ trashed: trashedMode,
1917
+ thenSort: extraSorts,
1918
+ });
1919
+
1920
+ // Tab badge counts. The live (non-fixed) counts are search-independent
1921
+ // totals that change only on writes, so they're cached per resource and
1922
+ // invalidated by AdminProvider on `ModelChanged`.
1923
+ const badgeTabs = tabs.filter((t) => t._badge);
1924
+ const liveTabs = badgeTabs.filter((t) => t._badgeValue === undefined);
1925
+ // Counts are per parent for a nested resource, so the cache key carries the
1926
+ // parent id — otherwise every parent would read the first one's totals.
1927
+ const countKey = this.parentId ? `${R.getSlug()}:${this.parentId}` : R.getSlug();
1928
+ const counts = liveTabs.length
1929
+ ? await rememberTabCounts(countKey, async () => {
1930
+ const out: Record<string, number> = {};
1931
+ await Promise.all(
1932
+ liveTabs.map(
1933
+ async (t) =>
1934
+ (out[t._key] = await R.count((q) =>
1935
+ t._modify ? t._modify(this._scopeToParent(q)) : this._scopeToParent(q),
1936
+ )),
1937
+ ),
1938
+ );
1939
+ return out;
1940
+ })
1941
+ : {};
1942
+ const tabBadges: Record<string, number | string> = {};
1943
+ for (const t of badgeTabs) tabBadges[t._key] = t._badgeValue ?? counts[t._key] ?? 0;
1944
+
1945
+ const allCols = R.columns();
1946
+ const hiddenCols = this._hiddenCols();
1947
+ const cols = allCols.filter((c) => !hiddenCols.has(c._key));
1948
+ // A tree resource arranges its page into parent/child order and remembers
1949
+ // how deep each row sits, so the first column can indent by it.
1950
+ const arranged = R.treeParentColumn ? R.arrangeTree(result.rows) : null;
1951
+ if (arranged) result.rows = arranged.map((a) => a.row);
1952
+ const depthOf = new Map(arranged?.map((a) => [String(a.row[R.primaryKey]), a.depth]) ?? []);
1953
+
1954
+ const tableColumns: TableColumn[] = cols.map((c, colIndex) => ({
1955
+ key: c._key,
1956
+ label: c.getLabel(),
1957
+ sortable: c._sortable,
1958
+ class: c._align === "end" ? "text-right" : c._align === "center" ? "text-center" : undefined,
1959
+ render: (row: Record<string, unknown>) => {
1960
+ const cell = this._cell(c, row);
1961
+ const depth = colIndex === 0 ? (depthOf.get(String(row[R.primaryKey])) ?? 0) : 0;
1962
+ if (depth === 0) return cell;
1963
+ // Indent the first column by depth, with a marker so a child reads as
1964
+ // one rather than as a row that happens to start further right.
1965
+ return (
1966
+ <span class="inline-flex items-center gap-1" style={`padding-left:${depth * 16}px`}>
1967
+ <span class="text-muted-foreground/50">└</span>
1968
+ {cell}
1969
+ </span>
1970
+ );
1971
+ },
1972
+ }));
1973
+
1974
+ // Leading selection column (only when the resource has bulk actions).
1975
+ const bulkActions = this._bulkActions();
1976
+ const pk = R.primaryKey;
1977
+ if (bulkActions.length > 0) {
1978
+ const pageIds = result.rows.map((r) => String(r[pk]));
1979
+ const allOnPage = pageIds.length > 0 && pageIds.every((id) => this.selected.includes(id));
1980
+ tableColumns.unshift({
1981
+ key: "__select",
1982
+ label: (
1983
+ <input
1984
+ type="checkbox"
1985
+ checked={allOnPage}
1986
+ onClick={this.toggleSelectAll}
1987
+ data-args={JSON.stringify([pageIds])}
1988
+ class="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-ring"
1989
+ />
1990
+ ),
1991
+ class: "w-1",
1992
+ render: (row: Record<string, unknown>) => (
1993
+ <input
1994
+ type="checkbox"
1995
+ checked={this.selected.includes(String(row[pk]))}
1996
+ onClick={this.toggleSelect}
1997
+ data-args={JSON.stringify([String(row[pk])])}
1998
+ class="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-ring"
1999
+ />
2000
+ ),
2001
+ });
2002
+ }
2003
+
2004
+ // Trailing row-actions column — renders the (soft-delete-aware) row actions
2005
+ // (View / Edit / Delete, plus Restore / Force-delete on trashed rows).
2006
+ const recordActions = this._rowActions();
2007
+ tableColumns.push({
2008
+ key: "__actions",
2009
+ label: "",
2010
+ class: "w-1 whitespace-nowrap text-right",
2011
+ render: (row: Record<string, unknown>) => {
2012
+ const ctx = this._ctx(row);
2013
+ const id = String(row[pk]);
2014
+ // A declared group renders as its own dropdown; loose actions are shown
2015
+ // inline until there are too many, then the surplus collapses into one.
2016
+ const groups = recordActions.filter((a): a is ActionGroup => a instanceof ActionGroup);
2017
+ const loose = recordActions.filter((a): a is Action => a instanceof Action);
2018
+ const visible = loose.filter((a) => a.isVisibleFor(row as AdminRecord, ctx));
2019
+ const inline = visible.length > 3 ? visible.slice(0, 2) : visible;
2020
+ const overflow = visible.length > 3 ? visible.slice(2) : [];
2021
+ return (
2022
+ <div class="flex items-center justify-end gap-1">
2023
+ {inline.map((a) =>
2024
+ renderAction(a, ctx, {
2025
+ onRun: this.runAction,
2026
+ onForm: this.openActionForm,
2027
+ args: [a._key, id],
2028
+ }),
2029
+ )}
2030
+ {groups.map((g) =>
2031
+ renderActionGroup(g, ctx, {
2032
+ onRun: this.runAction,
2033
+ onForm: this.openActionForm,
2034
+ argsFor: (a) => [a._key, id],
2035
+ }),
2036
+ )}
2037
+ {overflow.length > 0 ? (
2038
+ <DropdownMenu
2039
+ align="right"
2040
+ trigger={
2041
+ <button
2042
+ type="button"
2043
+ title="More actions"
2044
+ class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-lg leading-none text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
2045
+ >
2046
+
2047
+ </button>
2048
+ }
2049
+ >
2050
+ {overflow.map((a) =>
2051
+ renderActionMenuItem(a, ctx, {
2052
+ onRun: this.runAction,
2053
+ onForm: this.openActionForm,
2054
+ args: [a._key, id],
2055
+ }),
2056
+ )}
2057
+ </DropdownMenu>
2058
+ ) : null}
2059
+ </div>
2060
+ );
2061
+ },
2062
+ });
2063
+
2064
+ // Leading reorder column (drag-style up/down handles persist a position column).
2065
+ if (reorderCol) {
2066
+ const pageIds = result.rows.map((r) => String(r[pk]));
2067
+ const ctrl =
2068
+ "flex h-6 w-6 items-center justify-center rounded border border-input text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-30";
2069
+ tableColumns.unshift({
2070
+ key: "__reorder",
2071
+ label: "",
2072
+ class: "w-1",
2073
+ render: (row: Record<string, unknown>) => {
2074
+ const id = String(row[pk]);
2075
+ const idx = pageIds.indexOf(id);
2076
+ const atTop = result.page === 1 && idx === 0;
2077
+ const atBottom = result.page === result.lastPage && idx === pageIds.length - 1;
2078
+ return (
2079
+ <div class="flex flex-col gap-0.5">
2080
+ <button
2081
+ type="button"
2082
+ onClick={this.moveRow}
2083
+ data-args={JSON.stringify([id, -1])}
2084
+ disabled={atTop}
2085
+ class={ctrl}
2086
+ aria-label="Move up"
2087
+ >
2088
+ <Icon name="chevron-down" class="h-3.5 w-3.5 rotate-180" />
2089
+ </button>
2090
+ <button
2091
+ type="button"
2092
+ onClick={this.moveRow}
2093
+ data-args={JSON.stringify([id, 1])}
2094
+ disabled={atBottom}
2095
+ class={ctrl}
2096
+ aria-label="Move down"
2097
+ >
2098
+ <Icon name="chevron-down" class="h-3.5 w-3.5" />
2099
+ </button>
2100
+ </div>
2101
+ );
2102
+ },
2103
+ });
2104
+ }
2105
+
2106
+ // Column summaries — a table-level footer over the full filtered dataset, plus
2107
+ // per-group subtotals when a grouping is active.
2108
+ const summaryCols = cols.filter((c) => c.hasSummary());
2109
+ const hasSummaries = summaryCols.length > 0;
2110
+ const buildFooter = (rowsForCalc: Record<string, unknown>[]): unknown[] | undefined => {
2111
+ if (!hasSummaries) return undefined;
2112
+ let labelled = false;
2113
+ return tableColumns.map((tc) => {
2114
+ const col = cols.find((c) => c._key === tc.key);
2115
+ if (!col || !col.hasSummary()) {
2116
+ if (!labelled) {
2117
+ labelled = true;
2118
+ return <span class="text-xs font-semibold text-muted-foreground">Total</span>;
2119
+ }
2120
+ return null;
2121
+ }
2122
+ return (
2123
+ <div class="flex flex-col gap-0.5">
2124
+ {col.computeSummaries(rowsForCalc).map((it) => (
2125
+ <div class="whitespace-nowrap text-xs">
2126
+ <span class="text-muted-foreground">{it.label}: </span>
2127
+ <span class="font-semibold tabular-nums text-foreground">{it.text}</span>
2128
+ </div>
2129
+ ))}
2130
+ </div>
2131
+ );
2132
+ });
2133
+ };
2134
+ const allRows = hasSummaries
2135
+ ? await R.listAll({
2136
+ search: this.search || undefined,
2137
+ sortBy: querySortBy || undefined,
2138
+ sortDir,
2139
+ modifyQuery,
2140
+ trashed: trashedMode,
2141
+ })
2142
+ : [];
2143
+ const footerCells = buildFooter(allRows);
2144
+
2145
+ // Partition the page's rows into ordered groups.
2146
+ let tableGroups: TableGroup[] | undefined;
2147
+ if (activeGroup) {
2148
+ const buckets: Array<{ key: string; title: string; rows: Record<string, unknown>[] }> = [];
2149
+ const index = new Map<
2150
+ string,
2151
+ { key: string; title: string; rows: Record<string, unknown>[] }
2152
+ >();
2153
+ for (const row of result.rows) {
2154
+ const title = activeGroup.titleFor(row);
2155
+ let b = index.get(title);
2156
+ if (!b) {
2157
+ b = { key: title, title, rows: [] };
2158
+ index.set(title, b);
2159
+ buckets.push(b);
2160
+ }
2161
+ b.rows.push(row);
2162
+ }
2163
+ tableGroups = buckets.map((b) => ({
2164
+ key: b.key,
2165
+ header: (
2166
+ <span class="inline-flex items-center gap-2 text-sm">
2167
+ <span class="font-semibold">
2168
+ {activeGroup.getLabel()}: {b.title}
2169
+ </span>
2170
+ <span class="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
2171
+ {b.rows.length}
2172
+ </span>
2173
+ </span>
2174
+ ),
2175
+ rows: b.rows,
2176
+ footerCells: buildFooter(b.rows),
2177
+ }));
2178
+ }
2179
+
2180
+ // Contributed chrome for this screen. The context lets a hook target one
2181
+ // resource without registering a hook per resource.
2182
+ const hookCtx = { resource: R.getSlug(), page: "list" as const };
2183
+ const hook = (name: Parameters<typeof this._panel.renderHooks>[0]): (HtmlNode | string)[] =>
2184
+ resolveRenderHooks(this._panel.renderHooks(name), hookCtx);
2185
+
2186
+ const from = result.total === 0 ? 0 : (result.page - 1) * result.perPage + 1;
2187
+ const to = Math.min(result.page * result.perPage, result.total);
2188
+
2189
+ return (
2190
+ // A polling widget above the table refreshes the whole page, table included
2191
+ // — which is what someone watching a queue actually wants.
2192
+ <div
2193
+ class="mx-auto w-full max-w-7xl space-y-6"
2194
+ {...(widgetPoll ? { poll: { every: widgetPoll } } : {})}
2195
+ >
2196
+ {hook("page.header.start")}
2197
+
2198
+ {/* Header */}
2199
+ <div class="flex flex-wrap items-end justify-between gap-4">
2200
+ <div>
2201
+ <Breadcrumbs
2202
+ trail={resourceTrail({
2203
+ panel: this._panel,
2204
+ resource: R,
2205
+ parentId: this.parentId || undefined,
2206
+ parentTitle: parentTitle ?? undefined,
2207
+ })}
2208
+ />
2209
+ <h1 class="text-2xl font-semibold tracking-tight">{R.getPluralLabel()}</h1>
2210
+ <p class="mt-1 text-sm text-muted-foreground">
2211
+ {result.total}{" "}
2212
+ {result.total === 1 ? R.getLabel().toLowerCase() : R.getPluralLabel().toLowerCase()}
2213
+ </p>
2214
+ </div>
2215
+ <div class="flex items-center gap-2">
2216
+ {savedViews}
2217
+ {R.headerActions().map((a) =>
2218
+ a instanceof ActionGroup
2219
+ ? renderActionGroup(a, this._ctxBase(), {
2220
+ onRun: this.runAction,
2221
+ onForm: this.openActionForm,
2222
+ argsFor: (member) => [member._key, ""],
2223
+ })
2224
+ : renderAction(a, this._ctxBase(), {
2225
+ onRun: this.runAction,
2226
+ onForm: this.openActionForm,
2227
+ args: [a._key, ""],
2228
+ }),
2229
+ )}
2230
+ </div>
2231
+ </div>
2232
+
2233
+ {hook("page.header.end")}
2234
+
2235
+ {/* The resource's own widgets — what's going on in this list. */}
2236
+ {widgetBlock}
2237
+
2238
+ {/* Filter tabs */}
2239
+ {tabs.length > 0 ? (
2240
+ <div class="flex flex-wrap items-center gap-1 border-b border-border">
2241
+ {tabs.map((t) => {
2242
+ const isActive = t._key === activeKey;
2243
+ const badge = tabBadges[t._key];
2244
+ return (
2245
+ <a
2246
+ href={this._tabHref(t._key)}
2247
+ navigate
2248
+ class={`-mb-px inline-flex items-center gap-2 border-b-2 px-3 py-2 text-sm font-medium transition ${
2249
+ isActive
2250
+ ? "border-primary text-foreground"
2251
+ : "border-transparent text-muted-foreground hover:border-border hover:text-foreground"
2252
+ }`}
2253
+ >
2254
+ {t._icon ? <Icon name={t._icon} class="h-4 w-4" /> : null}
2255
+ {t.getLabel()}
2256
+ {badge !== undefined ? (
2257
+ <span
2258
+ class={`inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold ${BADGE_CLASS[t._badgeTone]}`}
2259
+ >
2260
+ {String(badge)}
2261
+ </span>
2262
+ ) : null}
2263
+ </a>
2264
+ );
2265
+ })}
2266
+ </div>
2267
+ ) : null}
2268
+
2269
+ {/* Locale switch, for a resource whose fields carry translations. */}
2270
+ {R.translatable.length > 0 && R.locales.length > 1 ? (
2271
+ <div class="inline-flex items-center gap-0.5 rounded-lg border border-border bg-card p-0.5 text-sm">
2272
+ {R.locales.map((code) => {
2273
+ const on = this._locale() === code;
2274
+ return (
2275
+ <a
2276
+ href={this._localeHref(code)}
2277
+ navigate
2278
+ class={`rounded-md px-3 py-1 font-medium uppercase transition ${
2279
+ on
2280
+ ? "bg-primary text-primary-foreground shadow-sm"
2281
+ : "text-muted-foreground hover:text-foreground"
2282
+ }`}
2283
+ >
2284
+ {code}
2285
+ </a>
2286
+ );
2287
+ })}
2288
+ </div>
2289
+ ) : null}
2290
+
2291
+ {/* Soft-delete scope switch (Active / All / Trashed). */}
2292
+ {R.usesSoftDeletes() ? (
2293
+ <div class="inline-flex items-center gap-0.5 rounded-lg border border-border bg-card p-0.5 text-sm">
2294
+ {[
2295
+ { v: "", label: "Active" },
2296
+ { v: "with", label: "All" },
2297
+ { v: "only", label: "Trashed" },
2298
+ ].map((opt) => {
2299
+ const on = (this.trashed || "") === opt.v;
2300
+ return (
2301
+ <a
2302
+ href={this._trashedHref(opt.v)}
2303
+ navigate
2304
+ class={`rounded-md px-3 py-1 font-medium transition ${
2305
+ on
2306
+ ? "bg-primary text-primary-foreground shadow-sm"
2307
+ : "text-muted-foreground hover:text-foreground"
2308
+ }`}
2309
+ >
2310
+ {opt.label}
2311
+ </a>
2312
+ );
2313
+ })}
2314
+ </div>
2315
+ ) : null}
2316
+
2317
+ {/* Query builders — stacked comparisons with nested AND/OR groups. */}
2318
+ {declaredFilters
2319
+ .filter((f) => f._type === "builder")
2320
+ .map((f) => this._queryBuilder(f, Boolean(active[f._key])))}
2321
+
2322
+ {/* Active-filter indicators — what is narrowing this list, and how to undo it. */}
2323
+ {this._filterIndicators(declaredFilters, active)}
2324
+
2325
+ {/* Filters — URL-driven; compose with tabs, search, sort, pagination. */}
2326
+ {declaredFilters.filter((f) => f._type !== "builder").length > 0
2327
+ ? this._filterBar(
2328
+ declaredFilters.filter((f) => f._type !== "builder"),
2329
+ active,
2330
+ )
2331
+ : null}
2332
+
2333
+ {/* Bulk action toolbar — shown while rows are selected. */}
2334
+ {this.selected.length > 0 && bulkActions.length > 0 ? (
2335
+ <div class="flex flex-wrap items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2">
2336
+ <span class="text-sm font-medium">{this.selected.length} selected</span>
2337
+ <div class="flex items-center gap-2">
2338
+ {bulkActions.map((a) =>
2339
+ a instanceof ActionGroup
2340
+ ? renderActionGroup(a, this._ctxBase(), {
2341
+ onRun: this.runBulkAction,
2342
+ onForm: this.openActionForm,
2343
+ argsFor: (member) => [member._key],
2344
+ })
2345
+ : renderAction(a, this._ctxBase(), {
2346
+ onRun: this.runBulkAction,
2347
+ onForm: this.openActionForm,
2348
+ args: [a._key],
2349
+ }),
2350
+ )}
2351
+ </div>
2352
+ <button
2353
+ type="button"
2354
+ onClick={this.clearSelection}
2355
+ class="ml-auto text-sm text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
2356
+ >
2357
+ Clear
2358
+ </button>
2359
+ </div>
2360
+ ) : null}
2361
+
2362
+ {/* Card: search + table */}
2363
+ <div class="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
2364
+ <div class="flex items-center justify-between gap-3 border-b border-border p-3">
2365
+ {R.searchableColumns().length > 0 ? (
2366
+ <div class="relative max-w-sm flex-1">
2367
+ <span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-muted-foreground">
2368
+ <Icon name="search" class="h-4 w-4" />
2369
+ </span>
2370
+ <input
2371
+ value={this.search}
2372
+ placeholder={`Search ${R.getPluralLabel().toLowerCase()}…`}
2373
+ class="h-9 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none transition focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background placeholder:text-muted-foreground"
2374
+ />
2375
+ </div>
2376
+ ) : (
2377
+ <span />
2378
+ )}
2379
+ <div class="flex items-center gap-2">
2380
+ {/* Group by */}
2381
+ {groups.length > 0 ? (
2382
+ <DropdownMenu
2383
+ align="right"
2384
+ trigger={
2385
+ <button
2386
+ type="button"
2387
+ class="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg border border-input bg-background px-3 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
2388
+ >
2389
+ <Icon name="collection" class="h-4 w-4" />
2390
+ {activeGroup ? `Grouped by ${activeGroup.getLabel()}` : "Group"}
2391
+ </button>
2392
+ }
2393
+ >
2394
+ <div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
2395
+ Group by
2396
+ </div>
2397
+ <a
2398
+ href={this._groupHref("")}
2399
+ navigate
2400
+ class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
2401
+ >
2402
+ <span class="flex h-4 w-4 items-center justify-center text-primary">
2403
+ {!activeGroup ? <Icon name="check-circle" class="h-4 w-4" /> : null}
2404
+ </span>
2405
+ None
2406
+ </a>
2407
+ {groups.map((g) => (
2408
+ <a
2409
+ href={this._groupHref(g.getColumn())}
2410
+ navigate
2411
+ class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
2412
+ >
2413
+ <span class="flex h-4 w-4 items-center justify-center text-primary">
2414
+ {activeGroup?.getColumn() === g.getColumn() ? (
2415
+ <Icon name="check-circle" class="h-4 w-4" />
2416
+ ) : null}
2417
+ </span>
2418
+ {g.getLabel()}
2419
+ </a>
2420
+ ))}
2421
+ </DropdownMenu>
2422
+ ) : null}
2423
+ {/* Column visibility manager */}
2424
+ {allCols.length > 1 ? (
2425
+ <DropdownMenu
2426
+ align="right"
2427
+ trigger={
2428
+ <button
2429
+ type="button"
2430
+ class="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg border border-input bg-background px-3 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
2431
+ >
2432
+ <Icon name="layout-grid" class="h-4 w-4" /> Columns
2433
+ </button>
2434
+ }
2435
+ >
2436
+ <div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
2437
+ Toggle columns
2438
+ </div>
2439
+ {allCols.map((c) => {
2440
+ const shown = !hiddenCols.has(c._key);
2441
+ return (
2442
+ <a
2443
+ href={this._colHref(c._key)}
2444
+ navigate
2445
+ class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
2446
+ >
2447
+ <span class="flex h-4 w-4 items-center justify-center text-primary">
2448
+ {shown ? <Icon name="check-circle" class="h-4 w-4" /> : null}
2449
+ </span>
2450
+ {c.getLabel()}
2451
+ </a>
2452
+ );
2453
+ })}
2454
+ </DropdownMenu>
2455
+ ) : null}
2456
+ </div>
2457
+ </div>
2458
+
2459
+ {hook("table.start")}
2460
+
2461
+ <div class={R.tableLayout === "table" ? "overflow-x-auto p-1.5" : "p-3"}>
2462
+ {result.rows.length === 0 ? (
2463
+ this._emptyState()
2464
+ ) : R.tableLayout === "grid" ? (
2465
+ this._grid(cols, result.rows, pk)
2466
+ ) : R.tableLayout === "kanban" && R.kanbanColumn ? (
2467
+ this._kanban(cols, result.rows, pk)
2468
+ ) : R.tableLayout === "calendar" && R.calendarColumn ? (
2469
+ this._calendar(cols, result.rows, pk)
2470
+ ) : (
2471
+ <Table
2472
+ columns={tableColumns}
2473
+ rows={result.rows}
2474
+ groups={tableGroups}
2475
+ footerCells={footerCells}
2476
+ sortBy={sortBy}
2477
+ sortDir={sortDir}
2478
+ params={{ search: this.search, tab: this.tab }}
2479
+ {...(headerFilters.length > 0
2480
+ ? { filterCells: this._headerFilterCells(cols) }
2481
+ : {})}
2482
+ hover
2483
+ {...(this._tableClass() ? { class: this._tableClass()! } : {})}
2484
+ {...(R.stickyHeader
2485
+ ? { theadClass: "sticky top-0 z-10 bg-card [&_th]:bg-card" }
2486
+ : {})}
2487
+ />
2488
+ )}
2489
+ </div>
2490
+
2491
+ {hook("table.end")}
2492
+
2493
+ {/* Footer: row count + per-page selector + pagination */}
2494
+ {result.total > 0 ? (
2495
+ <div class="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3 text-sm">
2496
+ <div class="flex items-center gap-2 text-muted-foreground">
2497
+ <span>
2498
+ Showing <span class="font-medium text-foreground">{from}</span>–
2499
+ <span class="font-medium text-foreground">{to}</span> of{" "}
2500
+ <span class="font-medium text-foreground">{result.total}</span>
2501
+ </span>
2502
+ <span class="mx-1 hidden sm:inline">·</span>
2503
+ <span class="hidden sm:inline">Per page:</span>
2504
+ <span class="hidden items-center gap-0.5 sm:inline-flex">
2505
+ {[10, 15, 25, 50].map((n) => {
2506
+ const on = (parseInt(this.perPage, 10) || R.perPage) === n;
2507
+ return (
2508
+ <a
2509
+ href={this._perPageHref(n)}
2510
+ navigate
2511
+ class={`rounded px-1.5 py-0.5 transition ${on ? "bg-accent font-semibold text-foreground" : "hover:text-foreground"}`}
2512
+ >
2513
+ {n}
2514
+ </a>
2515
+ );
2516
+ })}
2517
+ </span>
2518
+ </div>
2519
+ <Pagination
2520
+ page={result.page}
2521
+ lastPage={result.lastPage}
2522
+ total={result.total}
2523
+ perPage={result.perPage}
2524
+ href={(n) => this._pageHref(n)}
2525
+ />
2526
+ </div>
2527
+ ) : null}
2528
+ </div>
2529
+
2530
+ {/* Modal-form action host (opened by actions declared with `.form()`). */}
2531
+ {this._actionModal()}
2532
+ </div>
2533
+ );
2534
+ }
2535
+ }
2536
+
2537
+ /**
2538
+ * Build a uniquely-named List page subclass bound to a resource. The distinct
2539
+ * class name keeps Flow's snapshot/component identity stable per resource.
2540
+ */
2541
+ export function makeResourceListPage(
2542
+ resource: ResourceClass,
2543
+ panel: PanelInstance = Panel.default(),
2544
+ ): typeof ResourceListPage {
2545
+ const Page = class extends ResourceListPage {
2546
+ static override resource = resource;
2547
+ static override panel = panel;
2548
+ static override layout = makeAdminLayout(panel);
2549
+ };
2550
+ Object.defineProperty(Page, "name", { value: `${resource.getModelName()}ListPage` });
2551
+ return Page;
2552
+ }