@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
package/src/Resource.ts
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource — the declarative description of one model's admin interface. It
|
|
3
|
+
* carries the navigation metadata, the table columns, the form and infolist
|
|
4
|
+
* schemas, and a paginated, sortable, searchable record query backed by
|
|
5
|
+
* `@zerotal/orm`.
|
|
6
|
+
*
|
|
7
|
+
* export class UserResource extends Resource {
|
|
8
|
+
* static model = User;
|
|
9
|
+
* static navigationIcon = "users";
|
|
10
|
+
* static navigationGroup = "Access";
|
|
11
|
+
* static columns() {
|
|
12
|
+
* return [
|
|
13
|
+
* text("id").sortable(),
|
|
14
|
+
* text("name").searchable().sortable(),
|
|
15
|
+
* text("email").searchable(),
|
|
16
|
+
* text("created_at").label("Joined").since().sortable(),
|
|
17
|
+
* ];
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* Later phases add `form()`, `infolist()`, actions, and policies — none of which
|
|
22
|
+
* change this read-side contract.
|
|
23
|
+
*/
|
|
24
|
+
import { pluralize } from "@zerotal/core/helpers";
|
|
25
|
+
import type { ClusterClass } from "./Cluster.ts";
|
|
26
|
+
import type { Column } from "./table/Column.ts";
|
|
27
|
+
import type { Tab } from "./table/Tab.ts";
|
|
28
|
+
import type { Group } from "./table/Group.ts";
|
|
29
|
+
import type { Filter } from "./table/Filter.ts";
|
|
30
|
+
import type { InfolistComponent } from "./infolist/index.ts";
|
|
31
|
+
import type { FormComponent } from "./form/index.ts";
|
|
32
|
+
import { flattenFields } from "./form/index.ts";
|
|
33
|
+
import type { RelationManager } from "./relations/RelationManager.ts";
|
|
34
|
+
import type { DashboardWidget } from "./widgets/Widget.ts";
|
|
35
|
+
import {
|
|
36
|
+
type ActionItem,
|
|
37
|
+
viewAction,
|
|
38
|
+
editAction,
|
|
39
|
+
deleteAction,
|
|
40
|
+
createAction,
|
|
41
|
+
bulkDeleteAction,
|
|
42
|
+
} from "./actions/index.ts";
|
|
43
|
+
|
|
44
|
+
/** Loosely-typed view of an ORM query builder — avoids a hard dependency on @zerotal/orm. */
|
|
45
|
+
export interface AdminQuery {
|
|
46
|
+
where(column: string, operator: unknown, value?: unknown): AdminQuery;
|
|
47
|
+
/** Nest predicates into one parenthesised group, so an `OR` inside can't escape it. */
|
|
48
|
+
where(group: (query: AdminQuery) => void): AdminQuery;
|
|
49
|
+
orWhere?(column: string, operator: unknown, value?: unknown): AdminQuery;
|
|
50
|
+
orWhere?(group: (query: AdminQuery) => void): AdminQuery;
|
|
51
|
+
whereLike?(column: string, value: string): AdminQuery;
|
|
52
|
+
orWhereLike?(column: string, value: string): AdminQuery;
|
|
53
|
+
whereNotLike?(column: string, value: string): AdminQuery;
|
|
54
|
+
orWhereNotLike?(column: string, value: string): AdminQuery;
|
|
55
|
+
whereNull?(column: string): AdminQuery;
|
|
56
|
+
orWhereNull?(column: string): AdminQuery;
|
|
57
|
+
whereNotNull?(column: string): AdminQuery;
|
|
58
|
+
orWhereNotNull?(column: string): AdminQuery;
|
|
59
|
+
whereIn?(column: string, values: unknown[]): AdminQuery;
|
|
60
|
+
whereNotIn?(column: string, values: unknown[]): AdminQuery;
|
|
61
|
+
with?(relation: string): AdminQuery;
|
|
62
|
+
orderBy(column: string, direction?: "asc" | "desc"): AdminQuery;
|
|
63
|
+
limit(n: number): AdminQuery;
|
|
64
|
+
offset(n: number): AdminQuery;
|
|
65
|
+
count(): Promise<number>;
|
|
66
|
+
get?(): Promise<Record<string, unknown>[]>;
|
|
67
|
+
all?(): Promise<Record<string, unknown>[]>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Scopes a list query — used by tabs and ad-hoc filters. */
|
|
71
|
+
export type QueryModifier = (query: AdminQuery) => AdminQuery;
|
|
72
|
+
|
|
73
|
+
/** A loaded record — a plain row or a model instance with mutation helpers. */
|
|
74
|
+
export interface AdminRecord {
|
|
75
|
+
delete?(): Promise<void>;
|
|
76
|
+
fill?(data: Record<string, unknown>): unknown;
|
|
77
|
+
save?(): Promise<unknown>;
|
|
78
|
+
/** Soft-delete helpers (present on SoftDeletes models). */
|
|
79
|
+
restore?(): Promise<void>;
|
|
80
|
+
forceDelete?(): Promise<void>;
|
|
81
|
+
trashed?(): boolean;
|
|
82
|
+
[key: string]: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Loosely-typed view of an ORM model class. Row methods are intentionally `any`
|
|
87
|
+
* so concrete ORM model classes (whose instances lack a string index signature)
|
|
88
|
+
* remain assignable to `static model` without a cast.
|
|
89
|
+
*/
|
|
90
|
+
export interface AdminModel {
|
|
91
|
+
name?: string;
|
|
92
|
+
/** True for models using the SoftDeletes mixin. */
|
|
93
|
+
softDeletes?: boolean;
|
|
94
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
95
|
+
query?(): AdminQuery;
|
|
96
|
+
all?(): Promise<any[]>;
|
|
97
|
+
count?(): Promise<number>;
|
|
98
|
+
find?(id: unknown): Promise<any>;
|
|
99
|
+
create?(data: Record<string, unknown>): Promise<any>;
|
|
100
|
+
/** Soft-delete query scopes (SoftDeletes mixin). */
|
|
101
|
+
withTrashed?(): AdminQuery;
|
|
102
|
+
onlyTrashed?(): AdminQuery;
|
|
103
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What the list shows in place of a table when the resource holds nothing. */
|
|
107
|
+
export interface EmptyState {
|
|
108
|
+
heading: string;
|
|
109
|
+
/** A sentence explaining why it's empty and what fills it. */
|
|
110
|
+
description?: string;
|
|
111
|
+
/** Icon name from the panel's icon set. */
|
|
112
|
+
icon?: string;
|
|
113
|
+
/** Offered alongside the message — usually a create action. */
|
|
114
|
+
actions?: ActionItem[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface RecordPage {
|
|
118
|
+
rows: Record<string, unknown>[];
|
|
119
|
+
total: number;
|
|
120
|
+
page: number;
|
|
121
|
+
perPage: number;
|
|
122
|
+
lastPage: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ListOptions {
|
|
126
|
+
page?: number;
|
|
127
|
+
perPage?: number;
|
|
128
|
+
search?: string | undefined;
|
|
129
|
+
sortBy?: string | undefined;
|
|
130
|
+
sortDir?: "asc" | "desc";
|
|
131
|
+
/** Scope the base query before search/sort/pagination (e.g. the active tab). */
|
|
132
|
+
modifyQuery?: QueryModifier;
|
|
133
|
+
/** Soft-delete scope: include trashed (`with`) or only trashed (`only`). */
|
|
134
|
+
trashed?: "with" | "only" | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* Tie-breakers applied after the primary sort, in order. "By status, then
|
|
137
|
+
* newest first" is two entries rather than a bespoke query.
|
|
138
|
+
*/
|
|
139
|
+
thenSort?: readonly { column: string; direction: "asc" | "desc" }[] | undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export abstract class Resource {
|
|
143
|
+
/** The ORM model class this resource manages. */
|
|
144
|
+
static model: AdminModel;
|
|
145
|
+
|
|
146
|
+
/** URL slug; defaults to a kebab-cased plural of the model name. */
|
|
147
|
+
static slug?: string;
|
|
148
|
+
/** Singular label; defaults to the model name. */
|
|
149
|
+
static label?: string;
|
|
150
|
+
/** Plural label; defaults to the pluralized label. */
|
|
151
|
+
static pluralLabel?: string;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The {@link Cluster} this resource belongs to. Members share the cluster's
|
|
155
|
+
* URL segment and sit under one sidebar entry.
|
|
156
|
+
*/
|
|
157
|
+
static cluster?: ClusterClass;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Nest this resource under a parent record, so its pages live at
|
|
161
|
+
* `/admin/posts/7/comments` rather than `/admin/comments`. Every list query is
|
|
162
|
+
* scoped to the parent by `foreignKey`, and new records inherit it.
|
|
163
|
+
*
|
|
164
|
+
* static parent = { resource: () => PostResource, foreignKey: "post_id" };
|
|
165
|
+
*
|
|
166
|
+
* The parent is named by a function because the two resources almost always
|
|
167
|
+
* reference each other — the parent lists the child as a relation, the child
|
|
168
|
+
* names the parent here — and a direct reference would resolve to `undefined`
|
|
169
|
+
* on whichever side the module cycle happened to evaluate first.
|
|
170
|
+
*/
|
|
171
|
+
static parent?: { resource: () => typeof Resource; foreignKey: string };
|
|
172
|
+
|
|
173
|
+
/** The parent resource, resolved. */
|
|
174
|
+
static parentResource(): typeof Resource | undefined {
|
|
175
|
+
return this.parent?.resource();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Back a single row rather than a collection — site settings, a company
|
|
180
|
+
* profile. The resource mounts one route (`/admin/settings`) that opens the
|
|
181
|
+
* edit form directly; there is no list, no create page and no record id. The
|
|
182
|
+
* row is resolved by {@link singularRecord}, which creates it on first use.
|
|
183
|
+
*/
|
|
184
|
+
static singular = false;
|
|
185
|
+
|
|
186
|
+
/** Navigation icon key (see `ui/icons.ts`). */
|
|
187
|
+
static navigationIcon = "collection";
|
|
188
|
+
/** Optional sidebar group heading. */
|
|
189
|
+
static navigationGroup?: string;
|
|
190
|
+
/** Nest this item under another resource's nav label. */
|
|
191
|
+
static navigationParentItem?: string;
|
|
192
|
+
/** Sort order within the sidebar (lower = higher). */
|
|
193
|
+
static navigationSort = 0;
|
|
194
|
+
|
|
195
|
+
/** Tone for the sidebar navigation badge. */
|
|
196
|
+
static navigationBadgeColor: "primary" | "success" | "muted" | "destructive" = "primary";
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A count or label shown beside this item in the sidebar. Return
|
|
200
|
+
* `null`/`undefined` for no badge. Resolved on each render, so cache an
|
|
201
|
+
* expensive count rather than paying for it on every page.
|
|
202
|
+
*
|
|
203
|
+
* static async navigationBadge() { return this.count(); }
|
|
204
|
+
*/
|
|
205
|
+
static navigationBadge(): Promise<string | number | null> | string | number | null {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Default page size for the list table. */
|
|
210
|
+
static perPage = 15;
|
|
211
|
+
|
|
212
|
+
/** Primary-key column, used to build View links and to find/delete records. */
|
|
213
|
+
static primaryKey = "id";
|
|
214
|
+
|
|
215
|
+
/** Initial sort applied when the URL doesn't specify one. */
|
|
216
|
+
static defaultSort?: { column: string; direction?: "asc" | "desc" };
|
|
217
|
+
|
|
218
|
+
/** Relations to eager-load for list + view (so columns/entries can read them). */
|
|
219
|
+
static eager: string[] = [];
|
|
220
|
+
|
|
221
|
+
/** List-page filter tabs. Override to add them. */
|
|
222
|
+
static tabs(): Tab[] {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** List-page filters. Override to add them. */
|
|
227
|
+
static filters(): Filter[] {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Row groupings offered on the list table. */
|
|
232
|
+
static groups(): Group[] {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Grouping applied by default (a column key from {@link groups}). */
|
|
237
|
+
static defaultGroup?: string;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Enable drag-style row reordering by persisting a position column. Set to the
|
|
241
|
+
* integer column that stores order (e.g. `"sort"`); the list page then shows
|
|
242
|
+
* up/down reorder controls and orders by this column.
|
|
243
|
+
*/
|
|
244
|
+
static reorderable?: string;
|
|
245
|
+
|
|
246
|
+
/** Column used to title a record in global search, breadcrumbs, etc. */
|
|
247
|
+
static recordTitleAttribute?: string;
|
|
248
|
+
|
|
249
|
+
/** Resolve a human-readable title for a record. */
|
|
250
|
+
static recordTitle(record: Record<string, unknown>): string {
|
|
251
|
+
const attr = this.recordTitleAttribute;
|
|
252
|
+
if (attr && record[attr] != null) return String(record[attr]);
|
|
253
|
+
return String(record["name"] ?? record["title"] ?? record[this.primaryKey] ?? "Record");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Whether this resource participates in global search (has searchable columns). */
|
|
257
|
+
static globallySearchable(): boolean {
|
|
258
|
+
return this.searchableColumns().length > 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Relation managers shown on the View page. */
|
|
262
|
+
static relations(): RelationManager[] {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Table columns. Override in the subclass. */
|
|
267
|
+
static columns(): Column[] {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* View-page schema — an infolist: an ordered list of
|
|
273
|
+
* {@link Section}s and/or entries. Override to customize the detail page;
|
|
274
|
+
* when left empty it falls back to a single section derived from `columns()`.
|
|
275
|
+
*/
|
|
276
|
+
static infolist(): InfolistComponent[] {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Form schema for the Create/Edit pages: an ordered list of
|
|
282
|
+
* {@link Field}s. Use `.visibleOn("create")` / `.hiddenOn("edit")` to vary a
|
|
283
|
+
* field by page. An empty list disables Create/Edit for the resource.
|
|
284
|
+
*/
|
|
285
|
+
static form(): FormComponent[] {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Whether this resource exposes Create/Edit pages (any fields defined). */
|
|
290
|
+
static isEditable(): boolean {
|
|
291
|
+
return flattenFields(this.form()).length > 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Actions: per-row, above the table, and over a selection ─────────────────
|
|
295
|
+
//
|
|
296
|
+
// Any of these may return an {@link actionGroup} in place of an action, which
|
|
297
|
+
// collapses its members into one dropdown.
|
|
298
|
+
|
|
299
|
+
/** Per-row actions. Defaults to View + Edit + Delete; override to customize. */
|
|
300
|
+
static recordActions(): ActionItem[] {
|
|
301
|
+
return [viewAction(), editAction(), deleteAction()];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Actions shown above the table (defaults to a Create button). */
|
|
305
|
+
static headerActions(): ActionItem[] {
|
|
306
|
+
return [createAction()];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Actions applied to the selected rows (defaults to bulk Delete). */
|
|
310
|
+
static bulkActions(): ActionItem[] {
|
|
311
|
+
return [bulkDeleteAction()];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Show a history of changes on the record page, read from `@zerotal/audit`.
|
|
316
|
+
*
|
|
317
|
+
* Requires the model to compose `Auditable`; without it there is nothing
|
|
318
|
+
* recorded to show. An update can be put back from there.
|
|
319
|
+
*/
|
|
320
|
+
static history = false;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Allow operators to act as one of these records — a user resource, in
|
|
324
|
+
* practice. Off by default: `can()` defaults to allowing, which is the wrong
|
|
325
|
+
* default for becoming somebody else, so this carries the refusal.
|
|
326
|
+
*/
|
|
327
|
+
static impersonatable = false;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Guard against two people saving the same record over each other.
|
|
331
|
+
*
|
|
332
|
+
* Names the column holding the row's version — `updated_at` in almost every
|
|
333
|
+
* schema. The edit form carries the value it loaded, and a save whose value no
|
|
334
|
+
* longer matches is refused rather than silently overwriting the other change.
|
|
335
|
+
*/
|
|
336
|
+
static optimisticLock?: string;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Render this resource as a tree, nesting each record under its parent.
|
|
340
|
+
*
|
|
341
|
+
* Names the self-referencing column. The list orders and indents by depth, so
|
|
342
|
+
* categories or an org chart read as the shape they are.
|
|
343
|
+
*/
|
|
344
|
+
static treeParentColumn?: string;
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Fields stored per locale, as `{ en: "…", fr: "…" }` JSON columns.
|
|
348
|
+
*
|
|
349
|
+
* The form and table show one locale at a time, switched from the list; the
|
|
350
|
+
* stored shape is unchanged, so nothing outside the panel has to know.
|
|
351
|
+
*/
|
|
352
|
+
static translatable: string[] = [];
|
|
353
|
+
|
|
354
|
+
/** Locales offered when a resource is translatable. */
|
|
355
|
+
static locales: string[] = ["en"];
|
|
356
|
+
|
|
357
|
+
// ── Table presentation ──────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* How the list renders its records.
|
|
361
|
+
*
|
|
362
|
+
* `"table"` is right for data you scan and compare. `"grid"` suits records you
|
|
363
|
+
* recognise by sight — products, media, people — where a thumbnail and a name
|
|
364
|
+
* beat a row of columns.
|
|
365
|
+
*/
|
|
366
|
+
static tableLayout: "table" | "grid" | "kanban" | "calendar" = "table";
|
|
367
|
+
|
|
368
|
+
/** Column a kanban board groups its lanes by — a status, usually. */
|
|
369
|
+
static kanbanColumn?: string;
|
|
370
|
+
|
|
371
|
+
/** Lane order and labels for a kanban board, keyed by the column's values. */
|
|
372
|
+
static kanbanLanes: Record<string, string> = {};
|
|
373
|
+
|
|
374
|
+
/** Date column a calendar lays records out on. */
|
|
375
|
+
static calendarColumn?: string;
|
|
376
|
+
|
|
377
|
+
/** Shade alternating rows. Helps the eye track across a wide table. */
|
|
378
|
+
static striped = false;
|
|
379
|
+
|
|
380
|
+
/** Keep the header visible while the body scrolls. */
|
|
381
|
+
static stickyHeader = false;
|
|
382
|
+
|
|
383
|
+
/** Row height. `"compact"` fits noticeably more on screen. */
|
|
384
|
+
static density: "comfortable" | "compact" = "comfortable";
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Where the filters sit.
|
|
388
|
+
*
|
|
389
|
+
* `"inline"` keeps them above the table, which is fine for two or three.
|
|
390
|
+
* `"panel"` collapses them behind a Filters button, and `"drawer"` slides them
|
|
391
|
+
* in from the side — both worth it once filters outnumber the space for them.
|
|
392
|
+
*/
|
|
393
|
+
static filterLayout: "inline" | "panel" | "drawer" = "inline";
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Widgets shown above this resource's table, and on its record pages.
|
|
397
|
+
*
|
|
398
|
+
* The dashboard answers "how is the business doing"; these answer "what is
|
|
399
|
+
* going on in *this* list" — a pending count above the orders table, a revenue
|
|
400
|
+
* chart above products. Same widget builders as the dashboard, so `.poll()`
|
|
401
|
+
* works here too.
|
|
402
|
+
*
|
|
403
|
+
* static widgets() {
|
|
404
|
+
* return [statsWidget(async () => [stat("Pending", await Order.pending())])];
|
|
405
|
+
* }
|
|
406
|
+
*/
|
|
407
|
+
static widgets(): DashboardWidget[] {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* What a user sees instead of a table when there is nothing to show.
|
|
413
|
+
*
|
|
414
|
+
* A blank table teaches nobody anything. Override this to say why the list is
|
|
415
|
+
* empty and what to do about it — the difference between "No records" and
|
|
416
|
+
* "No orders yet. They'll appear here once a customer checks out."
|
|
417
|
+
*
|
|
418
|
+
* static emptyState() {
|
|
419
|
+
* return {
|
|
420
|
+
* heading: "No orders yet",
|
|
421
|
+
* description: "Orders appear here as soon as a customer checks out.",
|
|
422
|
+
* icon: "inbox",
|
|
423
|
+
* };
|
|
424
|
+
* }
|
|
425
|
+
*
|
|
426
|
+
* A search or filter that matches nothing gets a different, automatic message —
|
|
427
|
+
* this is for a genuinely empty resource.
|
|
428
|
+
*/
|
|
429
|
+
static emptyState(): EmptyState {
|
|
430
|
+
return {
|
|
431
|
+
heading: `No ${this.getPluralLabel().toLowerCase()} yet`,
|
|
432
|
+
icon: "inbox",
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Authorization gate (policies). Returns `true` by default; override to
|
|
438
|
+
* enforce permissions, e.g. delegate to `@zerotal/auth`'s Gate:
|
|
439
|
+
*
|
|
440
|
+
* static can(ability: string, record?: AdminRecord) {
|
|
441
|
+
* return Gate.allows(ability, record ?? this.model);
|
|
442
|
+
* }
|
|
443
|
+
*
|
|
444
|
+
* Abilities used by the built-in actions: `create`, `update`, `delete`,
|
|
445
|
+
* `restore`, `forceDelete`.
|
|
446
|
+
*/
|
|
447
|
+
static can(_ability: string, _record?: AdminRecord): boolean {
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── Form lifecycle hooks ────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/** Transform a record into form state before the Edit form is filled. */
|
|
454
|
+
static mutateFormDataBeforeFill(data: AdminRecord): AdminRecord {
|
|
455
|
+
return data;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Transform validated form data just before create/update. */
|
|
459
|
+
static mutateBeforeSave(data: AdminRecord, _mode: "create" | "edit"): AdminRecord {
|
|
460
|
+
return data;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Hook fired after a successful create/update (e.g. sync relations). */
|
|
464
|
+
static async afterSave(_record: AdminRecord, _mode: "create" | "edit"): Promise<void> {}
|
|
465
|
+
|
|
466
|
+
// ── Resolved metadata ──────────────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
static getModelName(): string {
|
|
469
|
+
return this.model?.name ?? this.name.replace(/Resource$/, "");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
static getLabel(): string {
|
|
473
|
+
return this.label ?? titleCase(this.getModelName());
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
static getPluralLabel(): string {
|
|
477
|
+
return this.pluralLabel ?? pluralize(this.getLabel());
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
static getSlug(): string {
|
|
481
|
+
return this.slug ?? kebab(pluralize(this.getModelName()));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Order rows so each sits under its parent, and report how deep each one is.
|
|
486
|
+
*
|
|
487
|
+
* Done in memory over the page's rows rather than in SQL: a recursive CTE is
|
|
488
|
+
* the right answer for a deep tree, but it is not portable across the drivers
|
|
489
|
+
* the panel supports, and a tree small enough to browse is small enough to
|
|
490
|
+
* arrange here. Orphans — rows whose parent is not in the set — are kept at the
|
|
491
|
+
* top level rather than dropped, so a filtered tree never hides records.
|
|
492
|
+
*/
|
|
493
|
+
static arrangeTree(
|
|
494
|
+
rows: Record<string, unknown>[],
|
|
495
|
+
): { row: Record<string, unknown>; depth: number }[] {
|
|
496
|
+
const column = this.treeParentColumn;
|
|
497
|
+
if (!column) return rows.map((row) => ({ row, depth: 0 }));
|
|
498
|
+
|
|
499
|
+
const pk = this.primaryKey;
|
|
500
|
+
const byParent = new Map<string, Record<string, unknown>[]>();
|
|
501
|
+
const ids = new Set(rows.map((r) => String(r[pk])));
|
|
502
|
+
|
|
503
|
+
for (const row of rows) {
|
|
504
|
+
const raw = row[column];
|
|
505
|
+
// A parent outside this page's rows is treated as no parent at all.
|
|
506
|
+
const parent = raw == null || !ids.has(String(raw)) ? "" : String(raw);
|
|
507
|
+
byParent.set(parent, [...(byParent.get(parent) ?? []), row]);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const out: { row: Record<string, unknown>; depth: number }[] = [];
|
|
511
|
+
const seen = new Set<string>();
|
|
512
|
+
const walk = (parent: string, depth: number): void => {
|
|
513
|
+
for (const row of byParent.get(parent) ?? []) {
|
|
514
|
+
const id = String(row[pk]);
|
|
515
|
+
// A cycle in the data must not become an infinite loop in the panel.
|
|
516
|
+
if (seen.has(id)) continue;
|
|
517
|
+
seen.add(id);
|
|
518
|
+
out.push({ row, depth });
|
|
519
|
+
walk(id, depth + 1);
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
walk("", 0);
|
|
523
|
+
|
|
524
|
+
// Anything a cycle kept out still belongs on screen.
|
|
525
|
+
for (const row of rows) if (!seen.has(String(row[pk]))) out.push({ row, depth: 0 });
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Read a translatable field for one locale.
|
|
531
|
+
*
|
|
532
|
+
* A translatable column stores `{ en: "…", fr: "…" }`; a value that was never
|
|
533
|
+
* translated is returned as-is, so turning translation on for an existing
|
|
534
|
+
* column does not blank it.
|
|
535
|
+
*/
|
|
536
|
+
static translated(value: unknown, locale: string): unknown {
|
|
537
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
538
|
+
const map = value as Record<string, unknown>;
|
|
539
|
+
return map[locale] ?? map[this.locales[0] ?? "en"] ?? "";
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── URLs ───────────────────────────────────────────────────────────────────
|
|
543
|
+
//
|
|
544
|
+
// Every link into a resource goes through these, so a resource can move into a
|
|
545
|
+
// cluster or under a parent record without any page having to know.
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The route pattern for this resource's index, relative to the panel base and
|
|
549
|
+
* with any parent id still a `:param` placeholder.
|
|
550
|
+
*/
|
|
551
|
+
static routePath(): string {
|
|
552
|
+
const segments: string[] = [];
|
|
553
|
+
if (this.cluster) segments.push(this.cluster.slug);
|
|
554
|
+
if (this.parent) {
|
|
555
|
+
segments.push(this.parentResource()!.getSlug(), `:${this.parentParam()}`);
|
|
556
|
+
}
|
|
557
|
+
segments.push(this.getSlug());
|
|
558
|
+
return segments.join("/");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Route-parameter name carrying the parent record's id, for a nested resource. */
|
|
562
|
+
static parentParam(): string {
|
|
563
|
+
return this.parent ? `${this.parentResource()!.getSlug().replace(/-/g, "_")}_parent` : "";
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* This resource's index URL under a panel base. Nested resources need the
|
|
568
|
+
* parent record's id; passing none leaves the placeholder in place.
|
|
569
|
+
*/
|
|
570
|
+
static indexUrl(base: string, parentId?: unknown): string {
|
|
571
|
+
const path = this.routePath();
|
|
572
|
+
const resolved =
|
|
573
|
+
this.parent && parentId != null
|
|
574
|
+
? path.replace(`:${this.parentParam()}`, String(parentId))
|
|
575
|
+
: path;
|
|
576
|
+
return `${base}/${resolved}`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
static recordUrl(base: string, id: unknown, parentId?: unknown): string {
|
|
580
|
+
return `${this.indexUrl(base, parentId)}/${String(id)}`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
static createUrl(base: string, parentId?: unknown): string {
|
|
584
|
+
return `${this.indexUrl(base, parentId)}/create`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
static editUrl(base: string, id: unknown, parentId?: unknown): string {
|
|
588
|
+
// A singular resource has exactly one row and no id in its URL.
|
|
589
|
+
if (this.singular) return this.indexUrl(base, parentId);
|
|
590
|
+
return `${this.recordUrl(base, id, parentId)}/edit`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Resolve the single row a {@link singular} resource edits, creating it from
|
|
595
|
+
* the form's defaults when it doesn't exist yet.
|
|
596
|
+
*/
|
|
597
|
+
static async singularRecord(): Promise<Record<string, unknown> | null> {
|
|
598
|
+
const existing = await this.listAll({ perPage: 1 });
|
|
599
|
+
if (existing[0]) return existing[0];
|
|
600
|
+
const defaults: Record<string, unknown> = {};
|
|
601
|
+
for (const field of flattenFields(this.form())) {
|
|
602
|
+
const value = field.defaultValue();
|
|
603
|
+
if (value !== undefined) defaults[field._key] = value;
|
|
604
|
+
}
|
|
605
|
+
return (await this.create(defaults)) as Record<string, unknown> | null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Database columns flagged `.searchable()` (honours `.column()` overrides). */
|
|
609
|
+
static searchableColumns(): string[] {
|
|
610
|
+
return this.columns()
|
|
611
|
+
.filter((c) => c._searchable)
|
|
612
|
+
.map((c) => c.getColumn());
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ── Data ─────────────────────────────────────────────────────────────────
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Rows for a resource that isn't backed by an ORM model — an external API, a
|
|
619
|
+
* config file, a computed report.
|
|
620
|
+
*
|
|
621
|
+
* Return the full set; the panel filters, sorts and paginates it in memory,
|
|
622
|
+
* so search, tabs, summaries and the query builder keep working. Returning
|
|
623
|
+
* `null` (the default) means "use `model`", which is the normal case.
|
|
624
|
+
*
|
|
625
|
+
* static async data() {
|
|
626
|
+
* return (await fetch("https://api.example.com/regions").then((r) => r.json()));
|
|
627
|
+
* }
|
|
628
|
+
*
|
|
629
|
+
* Writes are a separate question: a read-only source needs no `form()`, and a
|
|
630
|
+
* writable one overrides `create`, `update` and `destroy` to push changes back
|
|
631
|
+
* wherever they belong.
|
|
632
|
+
*/
|
|
633
|
+
static data(): Promise<Record<string, unknown>[] | null> | Record<string, unknown>[] | null {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Load a page of records honoring search, sort, and pagination. Defensive
|
|
639
|
+
* about the ORM surface so resources keep working under partial mocks/tests.
|
|
640
|
+
*/
|
|
641
|
+
static async records(options: ListOptions = {}): Promise<RecordPage> {
|
|
642
|
+
const perPage = options.perPage ?? this.perPage;
|
|
643
|
+
const page = Math.max(1, options.page ?? 1);
|
|
644
|
+
const search = options.search?.trim();
|
|
645
|
+
const sortBy = options.sortBy;
|
|
646
|
+
const sortDir: "asc" | "desc" = options.sortDir === "desc" ? "desc" : "asc";
|
|
647
|
+
|
|
648
|
+
const model = this.model;
|
|
649
|
+
// Fast path: real ORM query builder.
|
|
650
|
+
if (model && typeof model.query === "function") {
|
|
651
|
+
// Pick the soft-delete scope: default (active), with-trashed, or only-trashed.
|
|
652
|
+
const startQuery = (): AdminQuery => {
|
|
653
|
+
if (options.trashed === "only" && typeof model.onlyTrashed === "function")
|
|
654
|
+
return model.onlyTrashed();
|
|
655
|
+
if (options.trashed === "with" && typeof model.withTrashed === "function")
|
|
656
|
+
return model.withTrashed();
|
|
657
|
+
return model.query!();
|
|
658
|
+
};
|
|
659
|
+
const base = (): AdminQuery => {
|
|
660
|
+
let q = startQuery();
|
|
661
|
+
if (options.modifyQuery) q = options.modifyQuery(q);
|
|
662
|
+
for (const rel of this.eager) if (typeof q.with === "function") q = q.with(rel);
|
|
663
|
+
return q;
|
|
664
|
+
};
|
|
665
|
+
const applySearch = (q: AdminQuery): AdminQuery => {
|
|
666
|
+
const cols = this.searchableColumns();
|
|
667
|
+
if (!search || cols.length === 0 || typeof q.whereLike !== "function") return q;
|
|
668
|
+
// First column with whereLike; subsequent with orWhereLike when available.
|
|
669
|
+
let built = q.whereLike(cols[0]!, `%${search}%`);
|
|
670
|
+
for (const col of cols.slice(1)) {
|
|
671
|
+
built = (built.orWhereLike ?? built.whereLike)!.call(built, col, `%${search}%`);
|
|
672
|
+
}
|
|
673
|
+
return built;
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const total = await applySearch(base()).count();
|
|
677
|
+
let q = applySearch(base());
|
|
678
|
+
if (sortBy) q = q.orderBy(sortBy, sortDir);
|
|
679
|
+
for (const extra of options.thenSort ?? []) q = q.orderBy(extra.column, extra.direction);
|
|
680
|
+
q = q.limit(perPage).offset((page - 1) * perPage);
|
|
681
|
+
const rows = (await (q.get ?? q.all)?.call(q)) ?? [];
|
|
682
|
+
return paginateMeta(rows, total, page, perPage);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Fallback: load all, then filter/sort/page in memory. A custom `data()`
|
|
686
|
+
// source lands here too — same filtering, no query builder to talk to.
|
|
687
|
+
const custom = await this.data();
|
|
688
|
+
const all = custom ?? (model && (await model.all?.())) ?? [];
|
|
689
|
+
let rows = all as Record<string, unknown>[];
|
|
690
|
+
if (search) {
|
|
691
|
+
const cols = this.searchableColumns();
|
|
692
|
+
const needle = search.toLowerCase();
|
|
693
|
+
rows = rows.filter((r) =>
|
|
694
|
+
cols.some((c) =>
|
|
695
|
+
String(r[c] ?? "")
|
|
696
|
+
.toLowerCase()
|
|
697
|
+
.includes(needle),
|
|
698
|
+
),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
if (sortBy) {
|
|
702
|
+
rows = [...rows].sort(
|
|
703
|
+
(a, b) => compare(a[sortBy], b[sortBy]) * (sortDir === "desc" ? -1 : 1),
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
const total = rows.length;
|
|
707
|
+
const start = (page - 1) * perPage;
|
|
708
|
+
return paginateMeta(rows.slice(start, start + perPage), total, page, perPage);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Load *all* rows matching the current scope (search + tab/filters + trashed),
|
|
713
|
+
* with no pagination — used for column summaries and reorder swaps. Mirrors the
|
|
714
|
+
* scoping of {@link records} but skips `limit`/`offset`.
|
|
715
|
+
*/
|
|
716
|
+
static async listAll(options: ListOptions = {}): Promise<Record<string, unknown>[]> {
|
|
717
|
+
const search = options.search?.trim();
|
|
718
|
+
const sortBy = options.sortBy;
|
|
719
|
+
const sortDir: "asc" | "desc" = options.sortDir === "desc" ? "desc" : "asc";
|
|
720
|
+
const model = this.model;
|
|
721
|
+
|
|
722
|
+
if (model && typeof model.query === "function") {
|
|
723
|
+
const startQuery = (): AdminQuery => {
|
|
724
|
+
if (options.trashed === "only" && typeof model.onlyTrashed === "function")
|
|
725
|
+
return model.onlyTrashed();
|
|
726
|
+
if (options.trashed === "with" && typeof model.withTrashed === "function")
|
|
727
|
+
return model.withTrashed();
|
|
728
|
+
return model.query!();
|
|
729
|
+
};
|
|
730
|
+
let q = startQuery();
|
|
731
|
+
if (options.modifyQuery) q = options.modifyQuery(q);
|
|
732
|
+
for (const rel of this.eager) if (typeof q.with === "function") q = q.with(rel);
|
|
733
|
+
const cols = this.searchableColumns();
|
|
734
|
+
if (search && cols.length > 0 && typeof q.whereLike === "function") {
|
|
735
|
+
q = q.whereLike(cols[0]!, `%${search}%`);
|
|
736
|
+
for (const col of cols.slice(1)) {
|
|
737
|
+
q = (q.orWhereLike ?? q.whereLike)!.call(q, col, `%${search}%`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (sortBy) q = q.orderBy(sortBy, sortDir);
|
|
741
|
+
for (const extra of options.thenSort ?? []) q = q.orderBy(extra.column, extra.direction);
|
|
742
|
+
return (await (q.get ?? q.all)?.call(q)) ?? [];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Fallback: in-memory.
|
|
746
|
+
const custom = await this.data();
|
|
747
|
+
const all = custom ?? (model && (await model.all?.())) ?? [];
|
|
748
|
+
let rows = all as Record<string, unknown>[];
|
|
749
|
+
if (search) {
|
|
750
|
+
const cols = this.searchableColumns();
|
|
751
|
+
const needle = search.toLowerCase();
|
|
752
|
+
rows = rows.filter((r) =>
|
|
753
|
+
cols.some((c) =>
|
|
754
|
+
String(r[c] ?? "")
|
|
755
|
+
.toLowerCase()
|
|
756
|
+
.includes(needle),
|
|
757
|
+
),
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
if (sortBy) {
|
|
761
|
+
rows = [...rows].sort(
|
|
762
|
+
(a, b) => compare(a[sortBy], b[sortBy]) * (sortDir === "desc" ? -1 : 1),
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
return rows;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Count records, optionally scoped by a query modifier (used for tab badges). */
|
|
769
|
+
static async count(modifyQuery?: QueryModifier): Promise<number> {
|
|
770
|
+
const model = this.model;
|
|
771
|
+
if (model && typeof model.query === "function") {
|
|
772
|
+
const q = model.query();
|
|
773
|
+
return (modifyQuery ? modifyQuery(q) : q).count();
|
|
774
|
+
}
|
|
775
|
+
const custom = await this.data();
|
|
776
|
+
const all = custom ?? (model && (await model.all?.())) ?? [];
|
|
777
|
+
return all.length;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Load a single record by primary key, as a plain row (or `null`). Uses the
|
|
782
|
+
* query builder so the row shape matches {@link records}; falls back to the
|
|
783
|
+
* in-memory model surface used by tests/mocks.
|
|
784
|
+
*/
|
|
785
|
+
static async find(id: unknown): Promise<Record<string, unknown> | null> {
|
|
786
|
+
const model = this.model;
|
|
787
|
+
if (model && typeof model.query === "function") {
|
|
788
|
+
const run = async (q0: AdminQuery): Promise<Record<string, unknown> | null> => {
|
|
789
|
+
let q = q0;
|
|
790
|
+
for (const rel of this.eager) if (typeof q.with === "function") q = q.with(rel);
|
|
791
|
+
q = q.where(this.primaryKey, id).limit(1);
|
|
792
|
+
const rows = (await (q.get ?? q.all)?.call(q)) ?? [];
|
|
793
|
+
return rows[0] ?? null;
|
|
794
|
+
};
|
|
795
|
+
const found = await run(model.query());
|
|
796
|
+
if (found) return found;
|
|
797
|
+
// Trashed records are hidden by the default scope — retry including them so
|
|
798
|
+
// their View page (and Restore/ForceDelete) still resolve.
|
|
799
|
+
if (this.usesSoftDeletes() && typeof model.withTrashed === "function") {
|
|
800
|
+
return run(model.withTrashed());
|
|
801
|
+
}
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
const custom = await this.data();
|
|
805
|
+
const all = custom ?? (model && (await model.all?.())) ?? [];
|
|
806
|
+
return (
|
|
807
|
+
(all as Record<string, unknown>[]).find((r) => String(r[this.primaryKey]) === String(id)) ??
|
|
808
|
+
null
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Whether this resource's model uses soft deletes. */
|
|
813
|
+
static usesSoftDeletes(): boolean {
|
|
814
|
+
return !!this.model?.softDeletes;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Load a model *instance* (with restore/forceDelete), including trashed rows. */
|
|
818
|
+
private static async _findTrashedInstance(id: unknown): Promise<AdminRecord | null> {
|
|
819
|
+
const model = this.model;
|
|
820
|
+
if (model && typeof model.withTrashed === "function") {
|
|
821
|
+
const q = model.withTrashed().where(this.primaryKey, id).limit(1);
|
|
822
|
+
const rows = (await (q.get ?? q.all)?.call(q)) ?? [];
|
|
823
|
+
return (rows[0] as AdminRecord) ?? null;
|
|
824
|
+
}
|
|
825
|
+
if (model && typeof model.find === "function") {
|
|
826
|
+
return (await model.find(id)) as AdminRecord | null;
|
|
827
|
+
}
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Restore a soft-deleted record. Returns `true` when a record was restored. */
|
|
832
|
+
static async restore(id: unknown): Promise<boolean> {
|
|
833
|
+
const rec = await this._findTrashedInstance(id);
|
|
834
|
+
if (!rec || typeof rec.restore !== "function") return false;
|
|
835
|
+
await rec.restore();
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Permanently delete a (possibly soft-deleted) record. */
|
|
840
|
+
static async forceDelete(id: unknown): Promise<boolean> {
|
|
841
|
+
const rec = await this._findTrashedInstance(id);
|
|
842
|
+
if (!rec) return false;
|
|
843
|
+
if (typeof rec.forceDelete === "function") await rec.forceDelete();
|
|
844
|
+
else if (typeof rec.delete === "function") await rec.delete();
|
|
845
|
+
else return false;
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Permanently delete a record by primary key. Prefers loading the model
|
|
851
|
+
* instance and calling its `delete()` (so soft-deletes / model hooks run);
|
|
852
|
+
* returns `true` when a matching record was found and removed.
|
|
853
|
+
*/
|
|
854
|
+
static async destroy(id: unknown): Promise<boolean> {
|
|
855
|
+
const model = this.model;
|
|
856
|
+
if (model && typeof model.find === "function") {
|
|
857
|
+
const row = await model.find(id);
|
|
858
|
+
if (!row) return false;
|
|
859
|
+
await row.delete?.();
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** Create a record from form data. Returns the new record (with its id). */
|
|
866
|
+
static async create(data: Record<string, unknown>): Promise<AdminRecord | null> {
|
|
867
|
+
const model = this.model;
|
|
868
|
+
if (model && typeof model.create === "function") {
|
|
869
|
+
return await model.create(data);
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Update a record by primary key from form data. Returns `true` on success. */
|
|
875
|
+
static async update(id: unknown, data: Record<string, unknown>): Promise<boolean> {
|
|
876
|
+
const model = this.model;
|
|
877
|
+
if (model && typeof model.find === "function") {
|
|
878
|
+
const row = await model.find(id);
|
|
879
|
+
if (!row) return false;
|
|
880
|
+
row.fill?.(data);
|
|
881
|
+
await row.save?.();
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function paginateMeta(
|
|
889
|
+
rows: Record<string, unknown>[],
|
|
890
|
+
total: number,
|
|
891
|
+
page: number,
|
|
892
|
+
perPage: number,
|
|
893
|
+
): RecordPage {
|
|
894
|
+
return { rows, total, page, perPage, lastPage: Math.max(1, Math.ceil(total / perPage)) };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function compare(a: unknown, b: unknown): number {
|
|
898
|
+
if (a === b) return 0;
|
|
899
|
+
if (a === null || a === undefined) return -1;
|
|
900
|
+
if (b === null || b === undefined) return 1;
|
|
901
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
902
|
+
return String(a).localeCompare(String(b));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function titleCase(s: string): string {
|
|
906
|
+
return s
|
|
907
|
+
.replace(/[_-]+/g, " ")
|
|
908
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
909
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
910
|
+
.trim();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function kebab(s: string): string {
|
|
914
|
+
return s
|
|
915
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
916
|
+
.replace(/[_\s]+/g, "-")
|
|
917
|
+
.toLowerCase();
|
|
918
|
+
}
|