@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,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action — a declarative button that either navigates somewhere (a *link*
|
|
3
|
+
* action) or runs a server-side handler (a *callback* action), optionally behind
|
|
4
|
+
* a confirmation. One primitive powers row
|
|
5
|
+
* actions, header actions, and bulk actions.
|
|
6
|
+
*
|
|
7
|
+
* Handlers live on the server (resolved by key at run time), so nothing needs to
|
|
8
|
+
* cross the Flow snapshot except the action key and the record id(s) — which
|
|
9
|
+
* ride in `data-args`.
|
|
10
|
+
*
|
|
11
|
+
* action("activate")
|
|
12
|
+
* .label("Activate").icon("check-circle").color("success")
|
|
13
|
+
* .requiresConfirmation("Activate this account?")
|
|
14
|
+
* .run(async ({ record, resource }) => { await resource.update(record.id, { active: true }); })
|
|
15
|
+
* .successMessage("Account activated.")
|
|
16
|
+
*
|
|
17
|
+
* deleteAction() // confirm → resource.destroy(record)
|
|
18
|
+
* editAction() // link → /{slug}/{id}/edit
|
|
19
|
+
* bulkDeleteAction() // toolbar → destroy every selected record
|
|
20
|
+
*/
|
|
21
|
+
import type { ResourceClass } from "../Panel.ts";
|
|
22
|
+
import type { AdminRecord, ListOptions } from "../Resource.ts";
|
|
23
|
+
import type { Field } from "../form/Field.ts";
|
|
24
|
+
import { flattenFields } from "../form/index.ts";
|
|
25
|
+
|
|
26
|
+
export type ActionColor = "default" | "primary" | "success" | "muted" | "destructive";
|
|
27
|
+
|
|
28
|
+
/** Minimal view of the host page an action handler can drive (flash/redirect). */
|
|
29
|
+
export interface ActionPage {
|
|
30
|
+
flash(message: string, level?: string): unknown;
|
|
31
|
+
redirect(url: string): { withSuccess(message: string): unknown };
|
|
32
|
+
/** Send a generated file to the browser (present on every panel page). */
|
|
33
|
+
download?(filename: string, content: string | Uint8Array, mime?: string): unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Everything a handler (or link/visibility resolver) receives. */
|
|
37
|
+
export interface ActionContext {
|
|
38
|
+
resource: ResourceClass;
|
|
39
|
+
page: ActionPage;
|
|
40
|
+
/** Panel base path, e.g. "/admin". */
|
|
41
|
+
base: string;
|
|
42
|
+
/** Resource slug, e.g. "users". */
|
|
43
|
+
slug: string;
|
|
44
|
+
/** Which panel this action is running on — a queued job needs it to resolve the resource. */
|
|
45
|
+
panelId?: string | undefined;
|
|
46
|
+
/** Parent record's id, for a resource nested under another. */
|
|
47
|
+
parentId?: string | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* How the list page has currently scoped itself — search, sort, filters, tab,
|
|
50
|
+
* soft-delete mode. An export reads this so the file matches what's on screen.
|
|
51
|
+
*/
|
|
52
|
+
listOptions?: ListOptions | undefined;
|
|
53
|
+
/** Present for record (row/header-on-record) actions. */
|
|
54
|
+
record?: AdminRecord | undefined;
|
|
55
|
+
/** Present for bulk actions — the selected records. */
|
|
56
|
+
records?: AdminRecord[] | undefined;
|
|
57
|
+
/** Raw selected ids for bulk actions. */
|
|
58
|
+
ids?: string[] | undefined;
|
|
59
|
+
/** Submitted modal-form values (present for actions declared with `.form()`). */
|
|
60
|
+
data?: Record<string, unknown> | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ActionHandler = (ctx: ActionContext) => void | Promise<void>;
|
|
64
|
+
export type ActionVisible = (record: AdminRecord | undefined, ctx: ActionContext) => boolean;
|
|
65
|
+
|
|
66
|
+
export class Action {
|
|
67
|
+
/** @internal */ _key: string;
|
|
68
|
+
/** @internal */ _label?: string;
|
|
69
|
+
/** @internal */ _icon?: string;
|
|
70
|
+
/** @internal */ _color: ActionColor = "default";
|
|
71
|
+
/** @internal */ _confirm?: string;
|
|
72
|
+
/** @internal */ _hrefFn?: (ctx: ActionContext) => string;
|
|
73
|
+
/** @internal */ _handler?: ActionHandler;
|
|
74
|
+
/** @internal */ _visibleFn?: ActionVisible;
|
|
75
|
+
/** @internal */ _authorizeFn?: ActionVisible;
|
|
76
|
+
/** @internal */ _success?: string;
|
|
77
|
+
/** @internal */ _danger = false;
|
|
78
|
+
/** @internal */ _bulk = false;
|
|
79
|
+
/** @internal */ _iconOnly = false;
|
|
80
|
+
/** @internal Extra attributes a replicate action drops from the copy. */
|
|
81
|
+
_excludeAttributes: string[] = [];
|
|
82
|
+
/** @internal Last chance to adjust a replica before it is created. */
|
|
83
|
+
_beforeReplica?: (data: Record<string, unknown>) => Record<string, unknown>;
|
|
84
|
+
/** @internal Fields computed from what the modal currently holds. */
|
|
85
|
+
_formUsing?: (data: Record<string, unknown>, resource: ResourceClass) => Field[];
|
|
86
|
+
/** @internal Modal form fields (when set, the action opens a Dialog form). */
|
|
87
|
+
_form?: Field[];
|
|
88
|
+
/** @internal */ _modalHeading?: string;
|
|
89
|
+
/** @internal */ _modalSubmit?: string;
|
|
90
|
+
|
|
91
|
+
constructor(key: string) {
|
|
92
|
+
this._key = key;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static make(key: string): Action {
|
|
96
|
+
return new Action(key);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Appearance ─────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
label(label: string): this {
|
|
102
|
+
this._label = label;
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
icon(name: string): this {
|
|
107
|
+
this._icon = name;
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
color(color: ActionColor): this {
|
|
112
|
+
this._color = color;
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Render as an icon-only square button (row actions). */
|
|
117
|
+
iconButton(value = true): this {
|
|
118
|
+
this._iconOnly = value;
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Style as destructive (red). Shorthand for `.color("destructive")`. */
|
|
123
|
+
danger(value = true): this {
|
|
124
|
+
this._danger = value;
|
|
125
|
+
if (value) this._color = "destructive";
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Behaviour ──────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** Require a confirmation dialog before the handler runs (Flow `confirm`). */
|
|
132
|
+
requiresConfirmation(message = "Are you sure?"): this {
|
|
133
|
+
this._confirm = message;
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Make this a link action that navigates to the resolved URL. */
|
|
138
|
+
url(fn: (ctx: ActionContext) => string): this {
|
|
139
|
+
this._hrefFn = fn;
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build the modal's fields from what it currently holds, rather than fixing
|
|
145
|
+
* them upfront.
|
|
146
|
+
*
|
|
147
|
+
* This is what lets a modal have a second step: an import shows a file picker,
|
|
148
|
+
* and once a file is there, a mapping row per column in it. The fields are
|
|
149
|
+
* recomputed on every render, so the modal follows the data.
|
|
150
|
+
*/
|
|
151
|
+
formUsing(fn: (data: Record<string, unknown>, resource: ResourceClass) => Field[]): this {
|
|
152
|
+
this._formUsing = fn;
|
|
153
|
+
return this;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The fields to render for the modal's current state. */
|
|
157
|
+
fieldsFor(data: Record<string, unknown>, resource: ResourceClass): Field[] {
|
|
158
|
+
if (this._formUsing) return this._formUsing(data, resource);
|
|
159
|
+
return this._form ?? [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Make this a callback action that runs `fn` on the server. */
|
|
163
|
+
run(fn: ActionHandler): this {
|
|
164
|
+
this._handler = fn;
|
|
165
|
+
return this;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Attributes a {@link replicateAction} leaves behind, on top of the primary key
|
|
170
|
+
* and timestamps it always drops. Use it for anything that must stay unique —
|
|
171
|
+
* a slug, an invoice number, an external id.
|
|
172
|
+
*/
|
|
173
|
+
excludeAttributes(keys: string[]): this {
|
|
174
|
+
this._excludeAttributes = keys;
|
|
175
|
+
return this;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Adjust a replica's data just before it is created. */
|
|
179
|
+
beforeReplicaSaved(fn: (data: Record<string, unknown>) => Record<string, unknown>): this {
|
|
180
|
+
this._beforeReplica = fn;
|
|
181
|
+
return this;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Open a modal form before running. The submitted (validated) values arrive on
|
|
186
|
+
* `ctx.data`:
|
|
187
|
+
*
|
|
188
|
+
* action("ban").label("Ban user").icon("shield").color("destructive")
|
|
189
|
+
* .form([ textarea("reason").label("Reason").required() ])
|
|
190
|
+
* .run(async ({ record, resource, data, page }) => {
|
|
191
|
+
* await resource.update(record!.id, { banned: true, ban_reason: data!.reason });
|
|
192
|
+
* page.flash("User banned.");
|
|
193
|
+
* });
|
|
194
|
+
*/
|
|
195
|
+
form(fields: Field[]): this {
|
|
196
|
+
this._form = fields;
|
|
197
|
+
return this;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Heading shown at the top of the action's modal (defaults to the label). */
|
|
201
|
+
modalHeading(text: string): this {
|
|
202
|
+
this._modalHeading = text;
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Submit-button label inside the modal (defaults to the action label). */
|
|
207
|
+
modalSubmitLabel(text: string): this {
|
|
208
|
+
this._modalSubmit = text;
|
|
209
|
+
return this;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** True when this action opens a modal form. */
|
|
213
|
+
hasForm(): boolean {
|
|
214
|
+
if (this._formUsing) return true;
|
|
215
|
+
return Array.isArray(this._form) && this._form.length > 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Flash this message after a successful callback action. */
|
|
219
|
+
successMessage(message: string): this {
|
|
220
|
+
this._success = message;
|
|
221
|
+
return this;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Conditionally show the action for a given record. */
|
|
225
|
+
visible(fn: ActionVisible): this {
|
|
226
|
+
this._visibleFn = fn;
|
|
227
|
+
return this;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Permission gate — ANDed with `visible`. Decoupled from any specific auth
|
|
232
|
+
* package: pass a predicate (e.g. `() => Auth.can("update", record)`).
|
|
233
|
+
*/
|
|
234
|
+
authorize(fn: ActionVisible): this {
|
|
235
|
+
this._authorizeFn = fn;
|
|
236
|
+
return this;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @internal Mark as a bulk (multi-record) action. */
|
|
240
|
+
asBulk(): this {
|
|
241
|
+
this._bulk = true;
|
|
242
|
+
return this;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Resolution ─────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
getLabel(): string {
|
|
248
|
+
return this._label ?? titleCase(this._key);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
isLink(): boolean {
|
|
252
|
+
return typeof this._hrefFn === "function";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
isVisibleFor(record: AdminRecord | undefined, ctx: ActionContext): boolean {
|
|
256
|
+
if (this._visibleFn && !this._visibleFn(record, ctx)) return false;
|
|
257
|
+
if (this._authorizeFn && !this._authorizeFn(record, ctx)) return false;
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
href(ctx: ActionContext): string | null {
|
|
262
|
+
return this._hrefFn ? this._hrefFn(ctx) : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Run the callback (applies the success flash unless the handler redirected). */
|
|
266
|
+
async execute(ctx: ActionContext): Promise<void> {
|
|
267
|
+
if (!this._handler) return;
|
|
268
|
+
await this._handler(ctx);
|
|
269
|
+
if (this._success) ctx.page.flash(this._success);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Start a custom action. */
|
|
274
|
+
export function action(key: string): Action {
|
|
275
|
+
return new Action(key);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Presets ────────────────────────────────────────────────────────────────────
|
|
279
|
+
//
|
|
280
|
+
// Link presets route through the resource's own URL builders, so a resource that
|
|
281
|
+
// moves into a cluster or under a parent record keeps working untouched.
|
|
282
|
+
|
|
283
|
+
/** Link action → the record's View page. */
|
|
284
|
+
export function viewAction(): Action {
|
|
285
|
+
return new Action("view")
|
|
286
|
+
.label("View")
|
|
287
|
+
.icon("eye")
|
|
288
|
+
.iconButton()
|
|
289
|
+
.url(({ base, record, resource, parentId }) =>
|
|
290
|
+
resource.recordUrl(base, recordId(record, resource), parentId),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Link action → the record's Edit page (only shown for editable resources). */
|
|
295
|
+
export function editAction(): Action {
|
|
296
|
+
return new Action("edit")
|
|
297
|
+
.label("Edit")
|
|
298
|
+
.icon("pencil")
|
|
299
|
+
.iconButton()
|
|
300
|
+
.url(({ base, record, resource, parentId }) =>
|
|
301
|
+
resource.editUrl(base, recordId(record, resource), parentId),
|
|
302
|
+
)
|
|
303
|
+
.visible((_record, ctx) => ctx.resource.isEditable())
|
|
304
|
+
.authorize((rec, ctx) => ctx.resource.can("update", rec));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Confirmation callback action → `resource.destroy(record)`. */
|
|
308
|
+
export function deleteAction(): Action {
|
|
309
|
+
return new Action("delete")
|
|
310
|
+
.label("Delete")
|
|
311
|
+
.icon("trash")
|
|
312
|
+
.iconButton()
|
|
313
|
+
.danger()
|
|
314
|
+
.requiresConfirmation("Delete this record? This cannot be undone.")
|
|
315
|
+
.authorize((rec, ctx) => ctx.resource.can("delete", rec))
|
|
316
|
+
.run(async ({ resource, record, page }) => {
|
|
317
|
+
const id = recordId(record, resource);
|
|
318
|
+
const ok = await resource.destroy(id);
|
|
319
|
+
page.flash(ok ? `${resource.getLabel()} deleted.` : "That record no longer exists.");
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Several actions behind one dropdown.
|
|
325
|
+
*
|
|
326
|
+
* A row with seven buttons is unreadable; a row with two buttons and a "More"
|
|
327
|
+
* menu is not. A group is not itself runnable — it holds actions, and the page
|
|
328
|
+
* renders each member the way it would have rendered it inline.
|
|
329
|
+
*
|
|
330
|
+
* actionGroup([replicateAction(), archiveAction(), deleteAction()]).label("More")
|
|
331
|
+
*/
|
|
332
|
+
export class ActionGroup {
|
|
333
|
+
/** @internal */ _actions: Action[];
|
|
334
|
+
/** @internal */ _label = "More";
|
|
335
|
+
/** @internal */ _icon = "dots-horizontal";
|
|
336
|
+
|
|
337
|
+
constructor(actions: Action[]) {
|
|
338
|
+
this._actions = actions;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
label(label: string): this {
|
|
342
|
+
this._label = label;
|
|
343
|
+
return this;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
icon(name: string): this {
|
|
347
|
+
this._icon = name;
|
|
348
|
+
return this;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
getLabel(): string {
|
|
352
|
+
return this._label;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The members this user may actually see, in declaration order. */
|
|
356
|
+
visibleActions(record: AdminRecord | undefined, ctx: ActionContext): Action[] {
|
|
357
|
+
return this._actions.filter((a) => a.isVisibleFor(record, ctx));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Group actions into one dropdown. */
|
|
362
|
+
export function actionGroup(actions: Action[]): ActionGroup {
|
|
363
|
+
return new ActionGroup(actions);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Either a single action or a group of them — what a resource may return. */
|
|
367
|
+
export type ActionItem = Action | ActionGroup;
|
|
368
|
+
|
|
369
|
+
/** Flatten groups so callers that need plain actions (dispatch, lookup) get them. */
|
|
370
|
+
export function flattenActions(items: ActionItem[]): Action[] {
|
|
371
|
+
return items.flatMap((item) => (item instanceof ActionGroup ? item._actions : [item]));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Copy a record and open the copy for editing.
|
|
376
|
+
*
|
|
377
|
+
* The primary key and timestamps are always dropped — a replica is a new row,
|
|
378
|
+
* not a second claim on the original's identity. Add more with
|
|
379
|
+
* `.excludeAttributes()`, or adjust the copy with `.beforeReplicaSaved()`:
|
|
380
|
+
*
|
|
381
|
+
* replicateAction()
|
|
382
|
+
* .excludeAttributes(["slug"])
|
|
383
|
+
* .beforeReplicaSaved((data) => ({ ...data, title: `${data.title} (copy)` }))
|
|
384
|
+
*/
|
|
385
|
+
export function replicateAction(): Action {
|
|
386
|
+
// The handler closes over the action itself, so `.excludeAttributes()` and
|
|
387
|
+
// `.beforeReplicaSaved()` called later still reach it.
|
|
388
|
+
const act = new Action("replicate")
|
|
389
|
+
.label("Replicate")
|
|
390
|
+
.icon("duplicate")
|
|
391
|
+
.iconButton()
|
|
392
|
+
.visible((_record, ctx) => ctx.resource.isEditable() && !ctx.resource.singular)
|
|
393
|
+
.authorize((_rec, ctx) => ctx.resource.can("create"));
|
|
394
|
+
|
|
395
|
+
return act.run(async ({ resource, record, page, base, parentId }) => {
|
|
396
|
+
if (!record) return;
|
|
397
|
+
const dropped = new Set([
|
|
398
|
+
resource.primaryKey,
|
|
399
|
+
"created_at",
|
|
400
|
+
"updated_at",
|
|
401
|
+
"deleted_at",
|
|
402
|
+
...act._excludeAttributes,
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
let data: Record<string, unknown> = {};
|
|
406
|
+
for (const [key, value] of Object.entries(record as Record<string, unknown>)) {
|
|
407
|
+
// A model instance carries methods too; only columns should be copied.
|
|
408
|
+
if (dropped.has(key) || typeof value === "function") continue;
|
|
409
|
+
data[key] = value;
|
|
410
|
+
}
|
|
411
|
+
if (act._beforeReplica) data = act._beforeReplica(data);
|
|
412
|
+
|
|
413
|
+
const created = await resource.create(resource.mutateBeforeSave(data, "create"));
|
|
414
|
+
if (!created) {
|
|
415
|
+
page.flash("Could not replicate that record.", "warning");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
await resource.afterSave(created, "create");
|
|
419
|
+
const id = (created as Record<string, unknown>)[resource.primaryKey];
|
|
420
|
+
page
|
|
421
|
+
.redirect(resource.editUrl(base, id, parentId))
|
|
422
|
+
.withSuccess(`${resource.getLabel()} replicated.`);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Act as this user, and get a way back.
|
|
428
|
+
*
|
|
429
|
+
* Gated twice: the resource must opt in with `static impersonatable = true`,
|
|
430
|
+
* *and* its `can("impersonate", record)` must allow this record. `can()` alone
|
|
431
|
+
* defaults to allow, which is the wrong default for becoming another user, so
|
|
432
|
+
* the opt-in carries the refusal.
|
|
433
|
+
*/
|
|
434
|
+
export function impersonateAction(): Action {
|
|
435
|
+
return new Action("impersonate")
|
|
436
|
+
.label("Impersonate")
|
|
437
|
+
.icon("users")
|
|
438
|
+
.iconButton()
|
|
439
|
+
.requiresConfirmation("Act as this user? You will return with one click.")
|
|
440
|
+
.authorize((rec, ctx) => ctx.resource.impersonatable && ctx.resource.can("impersonate", rec))
|
|
441
|
+
.run(async ({ record, resource, page, base }) => {
|
|
442
|
+
const { startImpersonating } = await import("../impersonation.ts");
|
|
443
|
+
const id = recordId(record, resource);
|
|
444
|
+
const [ok, reason] = await startImpersonating(id);
|
|
445
|
+
if (!ok) {
|
|
446
|
+
page.flash(reason, "warning");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
page.redirect(base || "/").withSuccess("You are now acting as this user.");
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Header link action → the Create page. */
|
|
454
|
+
export function createAction(): Action {
|
|
455
|
+
return new Action("create")
|
|
456
|
+
.label("New")
|
|
457
|
+
.icon("plus")
|
|
458
|
+
.color("primary")
|
|
459
|
+
.url(({ base, resource, parentId }) => resource.createUrl(base, parentId))
|
|
460
|
+
.visible((_record, ctx) => ctx.resource.isEditable() && !ctx.resource.singular)
|
|
461
|
+
.authorize((_rec, ctx) => ctx.resource.can("create"));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Set one or more fields across every selected record.
|
|
466
|
+
*
|
|
467
|
+
* The modal offers the resource's own fields, and only what was actually filled
|
|
468
|
+
* in is written — leaving a field blank means "leave it alone" rather than
|
|
469
|
+
* "clear it", which is the only reading that makes a bulk edit safe to use.
|
|
470
|
+
*
|
|
471
|
+
* static bulkActions() {
|
|
472
|
+
* return [bulkEditAction(["status", "categoryId"]), bulkDeleteAction()];
|
|
473
|
+
* }
|
|
474
|
+
*
|
|
475
|
+
* Pass the field keys to offer, or omit them to offer every field the resource
|
|
476
|
+
* declares.
|
|
477
|
+
*/
|
|
478
|
+
export function bulkEditAction(fields?: string[]): Action {
|
|
479
|
+
return new Action("bulk-edit")
|
|
480
|
+
.label("Edit selected")
|
|
481
|
+
.icon("pencil")
|
|
482
|
+
.asBulk()
|
|
483
|
+
.modalHeading("Edit selected records")
|
|
484
|
+
.modalSubmitLabel("Apply")
|
|
485
|
+
.formUsing((_data, resource) => {
|
|
486
|
+
const all = flattenFields(resource.form());
|
|
487
|
+
const offered = fields ? all.filter((f) => fields.includes(f._key)) : all;
|
|
488
|
+
// Nothing is required here: a bulk edit sets what you fill in.
|
|
489
|
+
return offered.map((f) =>
|
|
490
|
+
f.required(false).helperText("Leave blank to keep each record's current value."),
|
|
491
|
+
);
|
|
492
|
+
})
|
|
493
|
+
.authorize((_rec, ctx) => ctx.resource.can("update"))
|
|
494
|
+
.run(async ({ ids = [], resource, data, page }) => {
|
|
495
|
+
const patch: Record<string, unknown> = {};
|
|
496
|
+
for (const [key, value] of Object.entries(data ?? {})) {
|
|
497
|
+
if (value === "" || value === null || value === undefined) continue;
|
|
498
|
+
patch[key] = value;
|
|
499
|
+
}
|
|
500
|
+
if (Object.keys(patch).length === 0) {
|
|
501
|
+
page.flash("Nothing to change — fill in at least one field.", "warning");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let changed = 0;
|
|
506
|
+
for (const id of ids) if (await resource.update(id, { ...patch })) changed++;
|
|
507
|
+
page.flash(
|
|
508
|
+
`Updated ${changed} ${changed === 1 ? resource.getLabel().toLowerCase() : resource.getPluralLabel().toLowerCase()}.`,
|
|
509
|
+
);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Bulk confirmation action → destroy every selected record. */
|
|
514
|
+
export function bulkDeleteAction(): Action {
|
|
515
|
+
return new Action("bulk-delete")
|
|
516
|
+
.label("Delete selected")
|
|
517
|
+
.icon("trash")
|
|
518
|
+
.danger()
|
|
519
|
+
.asBulk()
|
|
520
|
+
.requiresConfirmation("Delete the selected records? This cannot be undone.")
|
|
521
|
+
.run(async ({ resource, ids = [], page }) => {
|
|
522
|
+
let n = 0;
|
|
523
|
+
for (const id of ids) if (await resource.destroy(id)) n++;
|
|
524
|
+
page.flash(
|
|
525
|
+
`Deleted ${n} ${n === 1 ? resource.getLabel().toLowerCase() : resource.getPluralLabel().toLowerCase()}.`,
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Restore a soft-deleted record (shown on trashed rows). */
|
|
531
|
+
export function restoreAction(): Action {
|
|
532
|
+
return new Action("restore")
|
|
533
|
+
.label("Restore")
|
|
534
|
+
.icon("undo")
|
|
535
|
+
.iconButton()
|
|
536
|
+
.color("success")
|
|
537
|
+
.requiresConfirmation("Restore this record?")
|
|
538
|
+
.authorize((rec, ctx) => ctx.resource.can("restore", rec))
|
|
539
|
+
.run(async ({ resource, record, page }) => {
|
|
540
|
+
const ok = await resource.restore(recordId(record, resource));
|
|
541
|
+
page.flash(ok ? `${resource.getLabel()} restored.` : "Could not restore that record.");
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Permanently delete a record (shown on trashed rows). */
|
|
546
|
+
export function forceDeleteAction(): Action {
|
|
547
|
+
return new Action("force-delete")
|
|
548
|
+
.label("Delete permanently")
|
|
549
|
+
.icon("trash")
|
|
550
|
+
.iconButton()
|
|
551
|
+
.danger()
|
|
552
|
+
.requiresConfirmation("Permanently delete this record? This cannot be undone.")
|
|
553
|
+
.authorize((rec, ctx) => ctx.resource.can("forceDelete", rec))
|
|
554
|
+
.run(async ({ resource, record, page }) => {
|
|
555
|
+
const ok = await resource.forceDelete(recordId(record, resource));
|
|
556
|
+
page.flash(
|
|
557
|
+
ok ? `${resource.getLabel()} permanently deleted.` : "That record no longer exists.",
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Bulk restore every selected (trashed) record. */
|
|
563
|
+
export function bulkRestoreAction(): Action {
|
|
564
|
+
return new Action("bulk-restore")
|
|
565
|
+
.label("Restore selected")
|
|
566
|
+
.icon("undo")
|
|
567
|
+
.color("success")
|
|
568
|
+
.asBulk()
|
|
569
|
+
.requiresConfirmation("Restore the selected records?")
|
|
570
|
+
.run(async ({ resource, ids = [], page }) => {
|
|
571
|
+
let n = 0;
|
|
572
|
+
for (const id of ids) if (await resource.restore(id)) n++;
|
|
573
|
+
page.flash(
|
|
574
|
+
`Restored ${n} ${n === 1 ? resource.getLabel().toLowerCase() : resource.getPluralLabel().toLowerCase()}.`,
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Bulk permanently delete every selected record. */
|
|
580
|
+
export function bulkForceDeleteAction(): Action {
|
|
581
|
+
return new Action("bulk-force-delete")
|
|
582
|
+
.label("Delete permanently")
|
|
583
|
+
.icon("trash")
|
|
584
|
+
.danger()
|
|
585
|
+
.asBulk()
|
|
586
|
+
.requiresConfirmation("Permanently delete the selected records? This cannot be undone.")
|
|
587
|
+
.run(async ({ resource, ids = [], page }) => {
|
|
588
|
+
let n = 0;
|
|
589
|
+
for (const id of ids) if (await resource.forceDelete(id)) n++;
|
|
590
|
+
page.flash(
|
|
591
|
+
`Permanently deleted ${n} ${n === 1 ? resource.getLabel().toLowerCase() : resource.getPluralLabel().toLowerCase()}.`,
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function recordId(record: AdminRecord | undefined, resource: ResourceClass): string {
|
|
597
|
+
const pk = resource.primaryKey;
|
|
598
|
+
return String((record as Record<string, unknown> | undefined)?.[pk] ?? "");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function titleCase(key: string): string {
|
|
602
|
+
return key
|
|
603
|
+
.replace(/[_-]+/g, " ")
|
|
604
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
605
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
606
|
+
.trim();
|
|
607
|
+
}
|