@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.
- package/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/package.json +78 -0
- package/src/Cluster.ts +50 -0
- package/src/Panel.ts +288 -0
- package/src/PanelInstance.ts +644 -0
- package/src/Resource.ts +918 -0
- package/src/actions/Action.ts +607 -0
- package/src/actions/ImportRecordsJob.ts +108 -0
- package/src/actions/csv.ts +123 -0
- package/src/actions/index.ts +39 -0
- package/src/actions/render.tsx +181 -0
- package/src/actions/transfer.ts +307 -0
- package/src/actions/xlsx.ts +304 -0
- package/src/auth/AuthLayout.tsx +34 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
- package/src/auth/pages/LoginPage.tsx +121 -0
- package/src/auth/pages/ProfilePage.tsx +216 -0
- package/src/auth/pages/ResetPasswordPage.tsx +103 -0
- package/src/auth/pages/VerifyEmailPage.tsx +68 -0
- package/src/auth/register.ts +44 -0
- package/src/authRoles.ts +141 -0
- package/src/commands/MakeAdminResourceCommand.ts +181 -0
- package/src/config.ts +128 -0
- package/src/dashboardLayout.ts +101 -0
- package/src/databaseMedia.ts +148 -0
- package/src/databaseNotifications.ts +169 -0
- package/src/form/Field.ts +928 -0
- package/src/form/ResourceForm.ts +48 -0
- package/src/form/Section.ts +364 -0
- package/src/form/editors.ts +43 -0
- package/src/form/index.ts +59 -0
- package/src/history.ts +151 -0
- package/src/impersonation.ts +126 -0
- package/src/index.ts +380 -0
- package/src/infolist/Entry.ts +537 -0
- package/src/infolist/Section.ts +99 -0
- package/src/infolist/index.ts +38 -0
- package/src/media.ts +297 -0
- package/src/notifications.ts +65 -0
- package/src/pages/AdminPage.ts +100 -0
- package/src/pages/ConsolePage.tsx +324 -0
- package/src/pages/DashboardPage.tsx +264 -0
- package/src/pages/MediaPage.tsx +346 -0
- package/src/pages/NotificationsPage.tsx +155 -0
- package/src/pages/RecordViewPage.tsx +951 -0
- package/src/pages/ResourceFormPage.tsx +1856 -0
- package/src/pages/ResourceListPage.tsx +2552 -0
- package/src/pages/RolesPage.tsx +325 -0
- package/src/pages/SearchPage.tsx +169 -0
- package/src/plugin.ts +283 -0
- package/src/provider/AdminAbilityMiddleware.ts +25 -0
- package/src/provider/AdminGuardMiddleware.ts +29 -0
- package/src/provider/AdminProvider.ts +334 -0
- package/src/relations/RelationManager.ts +114 -0
- package/src/renderHooks.ts +86 -0
- package/src/roles.ts +175 -0
- package/src/savedViews.ts +79 -0
- package/src/support/ability.ts +73 -0
- package/src/support/authorize.ts +105 -0
- package/src/support/countCache.ts +37 -0
- package/src/support/hostPage.ts +30 -0
- package/src/table/Column.ts +353 -0
- package/src/table/Constraint.ts +238 -0
- package/src/table/Filter.ts +275 -0
- package/src/table/Group.ts +73 -0
- package/src/table/Tab.ts +77 -0
- package/src/testing.ts +121 -0
- package/src/theme.ts +70 -0
- package/src/ui/AdminLayout.tsx +355 -0
- package/src/ui/Breadcrumbs.tsx +84 -0
- package/src/ui/environmentIndicator.tsx +63 -0
- package/src/ui/icons.tsx +124 -0
- package/src/widgets/Widget.ts +251 -0
- 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
|
+
}
|