@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,951 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// The View page for a single record: an infolist — sections of labeled entries
|
|
3
|
+
// (badges, icons, dates, copy-to-clipboard, …) laid out in a responsive grid.
|
|
4
|
+
// The schema comes from `Resource.infolist()`, falling back to one section
|
|
5
|
+
// derived from `columns()`. The record id comes from the `/admin/{slug}/:id`
|
|
6
|
+
// route param, seeded once in onMount() and held on a @locked field so the
|
|
7
|
+
// reactive Delete action survives WS round-trips.
|
|
8
|
+
|
|
9
|
+
import { Component, locked, expose } from "@zerotal/flow";
|
|
10
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
11
|
+
import type { HttpContext } from "@zerotal/core";
|
|
12
|
+
import { Table } from "@zerotal/flow-ui";
|
|
13
|
+
import type { TableColumn } from "@zerotal/flow-ui";
|
|
14
|
+
import { AdminLayout, makeAdminLayout } from "../ui/AdminLayout.tsx";
|
|
15
|
+
import { Breadcrumbs, resourceTrail } from "../ui/Breadcrumbs.tsx";
|
|
16
|
+
import { Icon } from "../ui/icons.tsx";
|
|
17
|
+
import { resolveRenderHooks } from "../renderHooks.ts";
|
|
18
|
+
import { recordHistory, revertPayload } from "../history.ts";
|
|
19
|
+
import type { HistoryEntry } from "../history.ts";
|
|
20
|
+
import type { ResourceClass } from "../Panel.ts";
|
|
21
|
+
import { Panel } from "../Panel.ts";
|
|
22
|
+
import type { PanelInstance } from "../PanelInstance.ts";
|
|
23
|
+
import type { Column, BadgeTone } from "../table/Column.ts";
|
|
24
|
+
import type { RecordPage, AdminRecord } from "../Resource.ts";
|
|
25
|
+
import type { RelationManager } from "../relations/RelationManager.ts";
|
|
26
|
+
import {
|
|
27
|
+
ActionGroup,
|
|
28
|
+
flattenActions,
|
|
29
|
+
renderAction,
|
|
30
|
+
renderActionGroup,
|
|
31
|
+
restoreAction,
|
|
32
|
+
forceDeleteAction,
|
|
33
|
+
} from "../actions/index.ts";
|
|
34
|
+
import type { ActionContext, ActionPage } from "../actions/index.ts";
|
|
35
|
+
import { resolveInfolist } from "../infolist/index.ts";
|
|
36
|
+
import type { EntryDisplay, EntrySize, EntryWeight } from "../infolist/index.ts";
|
|
37
|
+
import { resolveMediaSrc } from "../media.ts";
|
|
38
|
+
import {
|
|
39
|
+
assertCan,
|
|
40
|
+
assertActionAllowed,
|
|
41
|
+
resolveDeclaredRelation,
|
|
42
|
+
resolveDeclaredRelationByName,
|
|
43
|
+
AdminForbiddenError,
|
|
44
|
+
} from "../support/authorize.ts";
|
|
45
|
+
|
|
46
|
+
const BADGE_CLASS: Record<BadgeTone, string> = {
|
|
47
|
+
default: "bg-secondary text-secondary-foreground",
|
|
48
|
+
primary: "bg-primary/10 text-primary ring-1 ring-inset ring-primary/20",
|
|
49
|
+
success: "bg-success/10 text-success ring-1 ring-inset ring-success/20",
|
|
50
|
+
muted: "bg-muted text-muted-foreground",
|
|
51
|
+
destructive: "bg-destructive/10 text-destructive ring-1 ring-inset ring-destructive/20",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const TONE_TEXT: Record<BadgeTone, string> = {
|
|
55
|
+
default: "text-foreground",
|
|
56
|
+
primary: "text-primary",
|
|
57
|
+
success: "text-success",
|
|
58
|
+
muted: "text-muted-foreground",
|
|
59
|
+
destructive: "text-destructive",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const WEIGHT_CLASS: Record<EntryWeight, string> = {
|
|
63
|
+
normal: "font-normal",
|
|
64
|
+
medium: "font-medium",
|
|
65
|
+
semibold: "font-semibold",
|
|
66
|
+
bold: "font-bold",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const SIZE_CLASS: Record<EntrySize, string> = {
|
|
70
|
+
sm: "text-xs",
|
|
71
|
+
base: "text-sm",
|
|
72
|
+
lg: "text-base",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Responsive grid templates for sections (1–4 columns) and entry spans.
|
|
76
|
+
const COLS_CLASS = [
|
|
77
|
+
"grid-cols-1",
|
|
78
|
+
"grid-cols-1",
|
|
79
|
+
"grid-cols-1 sm:grid-cols-2",
|
|
80
|
+
"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
|
|
81
|
+
"grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
|
|
82
|
+
];
|
|
83
|
+
const SPAN_CLASS = ["", "", "sm:col-span-2", "sm:col-span-3", "sm:col-span-4"];
|
|
84
|
+
|
|
85
|
+
export class RecordViewPage extends Component {
|
|
86
|
+
static layout = AdminLayout;
|
|
87
|
+
/** Set by each generated subclass. */
|
|
88
|
+
static resource: ResourceClass;
|
|
89
|
+
/** The panel this page belongs to — set by each generated subclass. */
|
|
90
|
+
static panel: PanelInstance;
|
|
91
|
+
|
|
92
|
+
@locked recordId = "";
|
|
93
|
+
/** The parent record's id, for a resource nested under another. */
|
|
94
|
+
@locked parentId = "";
|
|
95
|
+
|
|
96
|
+
private get _resource(): ResourceClass {
|
|
97
|
+
return (this.constructor as unknown as { resource: ResourceClass }).resource;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The panel this page was generated for. Held on the class rather than resolved
|
|
102
|
+
* from the request, so WebSocket actions — which carry no URL — stay on it.
|
|
103
|
+
*/
|
|
104
|
+
private get _panel(): PanelInstance {
|
|
105
|
+
return (this.constructor as typeof RecordViewPage).panel ?? Panel.current();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override async onMount(ctx?: HttpContext): Promise<void> {
|
|
109
|
+
const R = this._resource;
|
|
110
|
+
const parent = R.parent;
|
|
111
|
+
if (parent) {
|
|
112
|
+
const rawParent = ctx?.params?.[R.parentParam()];
|
|
113
|
+
if (rawParent != null) {
|
|
114
|
+
this.parentId = String(
|
|
115
|
+
rawParent && typeof rawParent === "object"
|
|
116
|
+
? (rawParent as Record<string, unknown>)[R.parentResource()!.primaryKey]
|
|
117
|
+
: rawParent,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const raw = ctx?.params?.[R.primaryKey] ?? ctx?.params?.["id"];
|
|
122
|
+
if (raw == null) return; // keep any pre-seeded id (e.g. tests)
|
|
123
|
+
// An implicitly-bound model resolves to an object; otherwise it's the raw segment.
|
|
124
|
+
this.recordId = String(
|
|
125
|
+
raw && typeof raw === "object" ? (raw as Record<string, unknown>)[R.primaryKey] : raw,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Where "back to the list" goes — inside the parent record, when nested. */
|
|
130
|
+
private _listHref(): string {
|
|
131
|
+
return this._resource.indexUrl(this._panel.base(), this.parentId || undefined);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@expose async deleteRecord(): Promise<void> {
|
|
135
|
+
const R = this._resource;
|
|
136
|
+
const current = (await R.find(this.recordId)) as Record<string, unknown> | null;
|
|
137
|
+
assertCan(R, "delete", current ?? undefined);
|
|
138
|
+
const ok = await R.destroy(this.recordId);
|
|
139
|
+
const listHref = this._listHref();
|
|
140
|
+
if (ok) this.redirect(listHref).withSuccess(`${R.getLabel()} deleted.`);
|
|
141
|
+
else this.redirect(listHref).withWarning("That record no longer exists.");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private _ctx(record?: Record<string, unknown>): ActionContext {
|
|
145
|
+
const R = this._resource;
|
|
146
|
+
return {
|
|
147
|
+
resource: R,
|
|
148
|
+
page: this as unknown as ActionPage,
|
|
149
|
+
base: this._panel.base(),
|
|
150
|
+
slug: R.getSlug(),
|
|
151
|
+
panelId: this._panel.id,
|
|
152
|
+
parentId: this.parentId || undefined,
|
|
153
|
+
record: record as AdminRecord | undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Run a record action (e.g. Delete) from the View header; leaves to the list if the record is gone. */
|
|
158
|
+
@expose async runAction(key: unknown): Promise<void> {
|
|
159
|
+
const R = this._resource;
|
|
160
|
+
// Include Restore / Force-delete as candidates for soft-delete resources.
|
|
161
|
+
const candidates = flattenActions(
|
|
162
|
+
R.usesSoftDeletes()
|
|
163
|
+
? [...R.recordActions(), restoreAction(), forceDeleteAction()]
|
|
164
|
+
: R.recordActions(),
|
|
165
|
+
);
|
|
166
|
+
const act = candidates.find((a) => a._key === key);
|
|
167
|
+
if (!act?._handler) return;
|
|
168
|
+
const record = await R.find(this.recordId);
|
|
169
|
+
const ctx = this._ctx((record as Record<string, unknown>) ?? undefined);
|
|
170
|
+
// Same gate the header renderer applies when deciding to draw the button.
|
|
171
|
+
assertActionAllowed(act, record as Record<string, unknown> | undefined, ctx);
|
|
172
|
+
await act.execute(ctx);
|
|
173
|
+
// After a destructive action the record may no longer exist — return to the list.
|
|
174
|
+
if (!(await R.find(this.recordId))) {
|
|
175
|
+
this.redirect(this._listHref());
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Put a record back to how it was before one recorded change.
|
|
181
|
+
*
|
|
182
|
+
* Authorised as an update, because that is exactly what it is — reverting is
|
|
183
|
+
* not a separate power from editing, and treating it as one would let someone
|
|
184
|
+
* rewrite a record they may not edit.
|
|
185
|
+
*/
|
|
186
|
+
@expose async revertChange(entryId: unknown): Promise<void> {
|
|
187
|
+
const R = this._resource;
|
|
188
|
+
const record = (await R.find(this.recordId)) as Record<string, unknown> | null;
|
|
189
|
+
assertCan(R, "update", record ?? undefined);
|
|
190
|
+
|
|
191
|
+
const entries = await this._history();
|
|
192
|
+
const entry = entries.find((e) => e.id === String(entryId));
|
|
193
|
+
if (!entry?.revertible) {
|
|
194
|
+
this.flash("That change can no longer be reverted.", "warning");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await R.update(this.recordId, revertPayload(entry));
|
|
199
|
+
this.flash(`Reverted ${entry.changes.length} field${entry.changes.length === 1 ? "" : "s"}.`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** This record's history, or an empty list when the resource didn't ask for it. */
|
|
203
|
+
private async _history(): Promise<HistoryEntry[]> {
|
|
204
|
+
const R = this._resource;
|
|
205
|
+
if (!R.history || !this.recordId) return [];
|
|
206
|
+
return recordHistory({ type: R.getModelName(), id: this.recordId });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The history card, or nothing when there is no history to show. */
|
|
210
|
+
private _historyCard(entries: HistoryEntry[]): HtmlNode | null {
|
|
211
|
+
if (entries.length === 0) return null;
|
|
212
|
+
return (
|
|
213
|
+
<div class="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
|
214
|
+
<div class="flex items-center gap-2 border-b border-border px-5 py-3">
|
|
215
|
+
<Icon name="calendar" class="h-4 w-4 text-muted-foreground" />
|
|
216
|
+
<h2 class="text-sm font-semibold tracking-tight">History</h2>
|
|
217
|
+
<span class="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
|
218
|
+
{entries.length}
|
|
219
|
+
</span>
|
|
220
|
+
</div>
|
|
221
|
+
<ol class="divide-y divide-border">
|
|
222
|
+
{entries.map((entry) => (
|
|
223
|
+
<li class="px-5 py-3">
|
|
224
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
225
|
+
<span
|
|
226
|
+
class={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
|
227
|
+
entry.event === "deleted"
|
|
228
|
+
? BADGE_CLASS.destructive
|
|
229
|
+
: entry.event === "created"
|
|
230
|
+
? BADGE_CLASS.success
|
|
231
|
+
: BADGE_CLASS.muted
|
|
232
|
+
}`}
|
|
233
|
+
>
|
|
234
|
+
{entry.event}
|
|
235
|
+
</span>
|
|
236
|
+
<span class="text-sm">{entry.actor ?? "System"}</span>
|
|
237
|
+
<span class="text-xs text-muted-foreground">{entry.at}</span>
|
|
238
|
+
{entry.revertible ? (
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
onClick={this.revertChange}
|
|
242
|
+
data-args={JSON.stringify([entry.id])}
|
|
243
|
+
confirm="Put these fields back to their previous values?"
|
|
244
|
+
class="ml-auto 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"
|
|
245
|
+
>
|
|
246
|
+
<Icon name="undo" class="h-3.5 w-3.5" /> Revert
|
|
247
|
+
</button>
|
|
248
|
+
) : null}
|
|
249
|
+
</div>
|
|
250
|
+
{entry.changes.length > 0 ? (
|
|
251
|
+
<dl class="mt-2 space-y-1">
|
|
252
|
+
{entry.changes.map((c) => (
|
|
253
|
+
<div class="flex flex-wrap items-baseline gap-2 text-xs">
|
|
254
|
+
<dt class="font-medium text-muted-foreground">{c.field}</dt>
|
|
255
|
+
<dd class="text-muted-foreground/70 line-through">
|
|
256
|
+
{stringifyValue(c.from)}
|
|
257
|
+
</dd>
|
|
258
|
+
<span class="text-muted-foreground">→</span>
|
|
259
|
+
<dd>{stringifyValue(c.to)}</dd>
|
|
260
|
+
</div>
|
|
261
|
+
))}
|
|
262
|
+
</dl>
|
|
263
|
+
) : null}
|
|
264
|
+
</li>
|
|
265
|
+
))}
|
|
266
|
+
</ol>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Entry rendering ────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
private _value(d: EntryDisplay): HtmlNode | string {
|
|
274
|
+
// A custom renderer takes the value outright — checked before the
|
|
275
|
+
// placeholder, since "empty" may be exactly what it wants to draw.
|
|
276
|
+
if (d.custom) return d.custom(d.raw, d.row);
|
|
277
|
+
|
|
278
|
+
if (d.isPlaceholder) {
|
|
279
|
+
return <span class="text-muted-foreground/70">{d.text}</span>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (d.kind === "icon" && d.boolean !== null) {
|
|
283
|
+
const tone = d.tone ?? (d.boolean ? "success" : "muted");
|
|
284
|
+
return (
|
|
285
|
+
<span class={`inline-flex items-center gap-1.5 ${TONE_TEXT[tone]}`}>
|
|
286
|
+
<Icon name={d.boolean ? "check-circle" : "x-circle"} class="h-5 w-5" />
|
|
287
|
+
<span class="text-sm">{d.text}</span>
|
|
288
|
+
</span>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (d.kind === "image") {
|
|
293
|
+
// A disk-relative path has to be resolved; printing it makes the browser
|
|
294
|
+
// fetch it relative to this record's own URL.
|
|
295
|
+
const src = resolveMediaSrc(d.text, this._panel.mediaDisk());
|
|
296
|
+
if (!src) return <span class="text-sm text-muted-foreground">—</span>;
|
|
297
|
+
return (
|
|
298
|
+
<img
|
|
299
|
+
src={src}
|
|
300
|
+
alt={d.label}
|
|
301
|
+
loading="lazy"
|
|
302
|
+
style={`height:${d.imageHeight}px`}
|
|
303
|
+
class={`mt-0.5 w-auto object-cover ${d.circular ? "rounded-full" : "rounded-md"} border border-border`}
|
|
304
|
+
/>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (d.kind === "color") {
|
|
309
|
+
return (
|
|
310
|
+
<span class="inline-flex items-center gap-2">
|
|
311
|
+
<span
|
|
312
|
+
style={`background:${d.text}`}
|
|
313
|
+
class="inline-block h-5 w-5 shrink-0 rounded-md border border-border"
|
|
314
|
+
/>
|
|
315
|
+
<span class="font-mono text-sm">{d.text}</span>
|
|
316
|
+
</span>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (d.kind === "code") {
|
|
321
|
+
return (
|
|
322
|
+
<div class="mt-0.5 overflow-hidden rounded-lg border border-border bg-muted/40">
|
|
323
|
+
{d.language ? (
|
|
324
|
+
<div class="border-b border-border px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
325
|
+
{d.language}
|
|
326
|
+
</div>
|
|
327
|
+
) : null}
|
|
328
|
+
<pre class="overflow-x-auto p-3 text-xs leading-relaxed">{d.text}</pre>
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (d.kind === "keyValue") {
|
|
334
|
+
return (
|
|
335
|
+
<dl class="mt-0.5 divide-y divide-border overflow-hidden rounded-lg border border-border">
|
|
336
|
+
{d.pairs.map((p) => (
|
|
337
|
+
<div class="flex gap-3 px-3 py-1.5 text-sm">
|
|
338
|
+
<dt class="w-1/3 shrink-0 truncate font-medium text-muted-foreground">{p.key}</dt>
|
|
339
|
+
<dd class="min-w-0 flex-1 break-words">{p.value}</dd>
|
|
340
|
+
</div>
|
|
341
|
+
))}
|
|
342
|
+
</dl>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (d.kind === "repeatable") {
|
|
347
|
+
return (
|
|
348
|
+
<div class="mt-0.5 space-y-2">
|
|
349
|
+
{d.items.map((item, i) => (
|
|
350
|
+
<div class="rounded-lg border border-border bg-background/60 p-3">
|
|
351
|
+
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
352
|
+
#{i + 1}
|
|
353
|
+
</div>
|
|
354
|
+
<div class="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
|
|
355
|
+
{item.map((nested) => (
|
|
356
|
+
<div>
|
|
357
|
+
<div class="text-xs font-medium text-muted-foreground">{nested.label}</div>
|
|
358
|
+
<div class="mt-0.5">{this._value(nested)}</div>
|
|
359
|
+
</div>
|
|
360
|
+
))}
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
))}
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let body: HtmlNode | string;
|
|
369
|
+
if (d.badge) {
|
|
370
|
+
const tone = d.tone ?? "default";
|
|
371
|
+
body = (
|
|
372
|
+
<span
|
|
373
|
+
class={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${BADGE_CLASS[tone]}`}
|
|
374
|
+
>
|
|
375
|
+
{d.icon ? <Icon name={d.icon} class="h-3.5 w-3.5" /> : null}
|
|
376
|
+
{d.text}
|
|
377
|
+
</span>
|
|
378
|
+
);
|
|
379
|
+
} else {
|
|
380
|
+
const tone = d.tone ? TONE_TEXT[d.tone] : "text-foreground";
|
|
381
|
+
const content = d.href ? (
|
|
382
|
+
<a href={d.href} navigate class="text-primary underline-offset-2 hover:underline">
|
|
383
|
+
{d.text}
|
|
384
|
+
</a>
|
|
385
|
+
) : (
|
|
386
|
+
d.text
|
|
387
|
+
);
|
|
388
|
+
body = (
|
|
389
|
+
<span
|
|
390
|
+
class={`inline-flex items-center gap-1.5 ${WEIGHT_CLASS[d.weight]} ${SIZE_CLASS[d.size]} ${tone}`}
|
|
391
|
+
>
|
|
392
|
+
{d.icon ? <Icon name={d.icon} class="h-4 w-4 text-muted-foreground" /> : null}
|
|
393
|
+
{content}
|
|
394
|
+
</span>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!d.copyValue) return body;
|
|
399
|
+
return (
|
|
400
|
+
<span class="inline-flex items-center gap-1.5">
|
|
401
|
+
{body}
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
data-copy={d.copyValue}
|
|
405
|
+
onclick="window.__zerotalCopy(this)"
|
|
406
|
+
title="Copy"
|
|
407
|
+
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-accent-foreground [&[data-copied]]:text-success"
|
|
408
|
+
>
|
|
409
|
+
<Icon name="copy" class="h-3.5 w-3.5" />
|
|
410
|
+
</button>
|
|
411
|
+
</span>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private _entryBlock(d: EntryDisplay): HtmlNode {
|
|
416
|
+
return (
|
|
417
|
+
<div class={SPAN_CLASS[Math.min(d.columnSpan, 4)]} title={d.tooltip ?? undefined}>
|
|
418
|
+
<dt class="text-xs font-medium uppercase tracking-wide text-muted-foreground/80">
|
|
419
|
+
{d.label}
|
|
420
|
+
</dt>
|
|
421
|
+
<dd class="mt-1">{this._value(d)}</dd>
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── Relation managers ──────────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
private _relCell(col: Column, row: Record<string, unknown>): HtmlNode | string {
|
|
429
|
+
const { text, badge } = col.cell(row);
|
|
430
|
+
if (!badge) return text;
|
|
431
|
+
return (
|
|
432
|
+
<span
|
|
433
|
+
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${BADGE_CLASS[badge]}`}
|
|
434
|
+
>
|
|
435
|
+
{text}
|
|
436
|
+
</span>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Render one HasMany relation manager as a card with a linked table. */
|
|
441
|
+
/** Delete a related (child) record from a relation table, then re-render. */
|
|
442
|
+
@expose async deleteRelated(slug: unknown, id: unknown): Promise<void> {
|
|
443
|
+
// `slug` used to go straight to Panel.find(), which resolves ANY registered resource — so
|
|
444
|
+
// from /admin/posts/1 a crafted frame could call deleteRelated("users", 1) and destroy an
|
|
445
|
+
// unrelated record. Resolve strictly from this resource's declared relations instead, and
|
|
446
|
+
// check the ability on the related resource.
|
|
447
|
+
const rel = resolveDeclaredRelation(this._resource, String(slug));
|
|
448
|
+
if (!rel) throw new AdminForbiddenError(`"${String(slug)}" is not a relation of this record`);
|
|
449
|
+
const related = rel._resource;
|
|
450
|
+
const child = (await related.find(id)) as Record<string, unknown> | null;
|
|
451
|
+
assertCan(related, "delete", child ?? undefined);
|
|
452
|
+
if (await related.destroy(id)) this.flash(`${related.getLabel()} deleted.`);
|
|
453
|
+
else this.flash("That record no longer exists.", "warning");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── BelongsToMany (attach / detach via the parent's pivot) ───────────────────
|
|
457
|
+
|
|
458
|
+
/** Pending "attach" selection per relation method. */
|
|
459
|
+
@expose attachDraft: Record<string, string> = {};
|
|
460
|
+
|
|
461
|
+
/** Resolve a parent model *instance* and its relation object by method name. */
|
|
462
|
+
private async _relation(
|
|
463
|
+
relationName: string,
|
|
464
|
+
): Promise<{ attach?: Function; detach?: Function; get?: Function; all?: Function } | null> {
|
|
465
|
+
const model = this._resource.model as unknown as {
|
|
466
|
+
find?: (id: unknown) => Promise<Record<string, unknown> | null>;
|
|
467
|
+
};
|
|
468
|
+
if (typeof model?.find !== "function") return null;
|
|
469
|
+
const parent = (await model.find(this.recordId)) as Record<string, unknown> | null;
|
|
470
|
+
const fn = parent && (parent as Record<string, unknown>)[relationName];
|
|
471
|
+
if (typeof fn !== "function") return null;
|
|
472
|
+
return (fn as () => unknown).call(parent) as {
|
|
473
|
+
attach?: Function;
|
|
474
|
+
detach?: Function;
|
|
475
|
+
get?: Function;
|
|
476
|
+
all?: Function;
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Load the rows currently attached through a BelongsToMany relation. */
|
|
481
|
+
private async _attachedRows(relationName: string): Promise<Record<string, unknown>[]> {
|
|
482
|
+
const rel = await this._relation(relationName);
|
|
483
|
+
if (!rel) return [];
|
|
484
|
+
const getter = rel.get ?? rel.all;
|
|
485
|
+
if (typeof getter !== "function") return [];
|
|
486
|
+
const rows = (await getter.call(rel)) as Record<string, unknown>[] | undefined;
|
|
487
|
+
return rows ?? [];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Attach the selected related record to the parent via the pivot. */
|
|
491
|
+
@expose async attachRelated(relationName: unknown): Promise<void> {
|
|
492
|
+
const name = String(relationName);
|
|
493
|
+
// The raw name was invoked as a method on the parent model, so any zero-argument method
|
|
494
|
+
// reachable there could be called. Accept only names this resource declares as attachable.
|
|
495
|
+
const declared = resolveDeclaredRelationByName(this._resource, name);
|
|
496
|
+
if (!declared) throw new AdminForbiddenError(`"${name}" is not an attachable relation`);
|
|
497
|
+
assertCan(this._resource, "update");
|
|
498
|
+
const id = this.attachDraft[name];
|
|
499
|
+
if (!id) return;
|
|
500
|
+
const rel = await this._relation(name);
|
|
501
|
+
if (rel && typeof rel.attach === "function") {
|
|
502
|
+
await rel.attach(id);
|
|
503
|
+
this.flash("Attached.");
|
|
504
|
+
}
|
|
505
|
+
this.attachDraft = { ...this.attachDraft, [name]: "" };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Detach a related record from the parent via the pivot. */
|
|
509
|
+
@expose async detachRelated(relationName: unknown, id: unknown): Promise<void> {
|
|
510
|
+
const name = String(relationName);
|
|
511
|
+
const declared = resolveDeclaredRelationByName(this._resource, name);
|
|
512
|
+
if (!declared) throw new AdminForbiddenError(`"${name}" is not an attachable relation`);
|
|
513
|
+
assertCan(this._resource, "update");
|
|
514
|
+
const rel = await this._relation(name);
|
|
515
|
+
if (rel && typeof rel.detach === "function") {
|
|
516
|
+
await rel.detach(id);
|
|
517
|
+
this.flash("Detached.");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private _relationCard(rel: RelationManager, result: RecordPage, parentId: unknown): HtmlNode {
|
|
522
|
+
const base = this._panel.base();
|
|
523
|
+
const related = rel._resource;
|
|
524
|
+
// A related resource that declares this one as its parent has its own nested
|
|
525
|
+
// pages, so its links go through the parent record rather than to a bare
|
|
526
|
+
// top-level list.
|
|
527
|
+
const relBase = related.indexUrl(base, parentId);
|
|
528
|
+
const iconBtn =
|
|
529
|
+
"inline-flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition";
|
|
530
|
+
|
|
531
|
+
const tableColumns: TableColumn[] = related.columns().map((c) => ({
|
|
532
|
+
key: c._key,
|
|
533
|
+
label: c.getLabel(),
|
|
534
|
+
class: c._align === "end" ? "text-right" : c._align === "center" ? "text-center" : undefined,
|
|
535
|
+
render: (row: Record<string, unknown>) => this._relCell(c, row),
|
|
536
|
+
}));
|
|
537
|
+
// Row actions: View, Edit (if editable), Delete.
|
|
538
|
+
tableColumns.push({
|
|
539
|
+
key: "__actions",
|
|
540
|
+
label: "",
|
|
541
|
+
class: "w-1 whitespace-nowrap text-right",
|
|
542
|
+
render: (row: Record<string, unknown>) => {
|
|
543
|
+
const id = String(row[related.primaryKey]);
|
|
544
|
+
return (
|
|
545
|
+
<div class="flex items-center justify-end gap-1">
|
|
546
|
+
<a
|
|
547
|
+
href={`${relBase}/${id}`}
|
|
548
|
+
navigate
|
|
549
|
+
title="View"
|
|
550
|
+
class={`${iconBtn} hover:bg-accent hover:text-accent-foreground`}
|
|
551
|
+
>
|
|
552
|
+
<Icon name="eye" class="h-4 w-4" />
|
|
553
|
+
</a>
|
|
554
|
+
{related.isEditable() ? (
|
|
555
|
+
<a
|
|
556
|
+
href={`${relBase}/${id}/edit`}
|
|
557
|
+
navigate
|
|
558
|
+
title="Edit"
|
|
559
|
+
class={`${iconBtn} hover:bg-accent hover:text-accent-foreground`}
|
|
560
|
+
>
|
|
561
|
+
<Icon name="pencil" class="h-4 w-4" />
|
|
562
|
+
</a>
|
|
563
|
+
) : null}
|
|
564
|
+
<button
|
|
565
|
+
type="button"
|
|
566
|
+
onClick={this.deleteRelated}
|
|
567
|
+
data-args={JSON.stringify([related.getSlug(), id])}
|
|
568
|
+
confirm="Delete this record? This cannot be undone."
|
|
569
|
+
title="Delete"
|
|
570
|
+
class={`${iconBtn} hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive`}
|
|
571
|
+
>
|
|
572
|
+
<Icon name="trash" class="h-4 w-4" />
|
|
573
|
+
</button>
|
|
574
|
+
</div>
|
|
575
|
+
);
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
return (
|
|
580
|
+
<div class="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
|
581
|
+
<div class="flex items-center justify-between gap-3 border-b border-border px-5 py-3">
|
|
582
|
+
<h2 class="flex items-center gap-2 text-sm font-semibold tracking-tight">
|
|
583
|
+
{rel._icon ? <Icon name={rel._icon} class="h-4 w-4 text-muted-foreground" /> : null}
|
|
584
|
+
{rel.getTitle()}
|
|
585
|
+
<span class="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
|
586
|
+
{result.total}
|
|
587
|
+
</span>
|
|
588
|
+
</h2>
|
|
589
|
+
{related.isEditable() && rel._canCreate ? (
|
|
590
|
+
<a
|
|
591
|
+
href={
|
|
592
|
+
// A nested resource takes the parent from the URL; a plain one
|
|
593
|
+
// needs the foreign key seeded through the query string.
|
|
594
|
+
related.parent
|
|
595
|
+
? related.createUrl(base, parentId)
|
|
596
|
+
: `${relBase}/create?${rel._foreignKey}=${encodeURIComponent(String(parentId))}`
|
|
597
|
+
}
|
|
598
|
+
navigate
|
|
599
|
+
class="inline-flex h-8 items-center gap-1 rounded-lg border border-input bg-background px-3 text-xs font-medium transition hover:bg-accent hover:text-accent-foreground"
|
|
600
|
+
>
|
|
601
|
+
<Icon name="plus" class="h-3.5 w-3.5" /> New
|
|
602
|
+
</a>
|
|
603
|
+
) : null}
|
|
604
|
+
</div>
|
|
605
|
+
<div class="overflow-x-auto p-1.5">
|
|
606
|
+
{result.rows.length > 0 ? (
|
|
607
|
+
<Table columns={tableColumns} rows={result.rows} hover />
|
|
608
|
+
) : (
|
|
609
|
+
<p class="px-4 py-8 text-center text-sm text-muted-foreground">
|
|
610
|
+
No {related.getPluralLabel().toLowerCase()} yet.
|
|
611
|
+
</p>
|
|
612
|
+
)}
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Render a BelongsToMany relation: attached rows + Detach + an Attach picker. */
|
|
619
|
+
private _btmCard(
|
|
620
|
+
rel: RelationManager,
|
|
621
|
+
rows: Record<string, unknown>[],
|
|
622
|
+
options: Record<string, unknown>[],
|
|
623
|
+
): HtmlNode {
|
|
624
|
+
const base = this._panel.base();
|
|
625
|
+
const related = rel._resource;
|
|
626
|
+
// Many-to-many has no single owning record, so these always link to the
|
|
627
|
+
// related resource's own top-level pages.
|
|
628
|
+
const relBase = related.indexUrl(base);
|
|
629
|
+
const iconBtn =
|
|
630
|
+
"inline-flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition";
|
|
631
|
+
|
|
632
|
+
const tableColumns: TableColumn[] = related.columns().map((c) => ({
|
|
633
|
+
key: c._key,
|
|
634
|
+
label: c.getLabel(),
|
|
635
|
+
class: c._align === "end" ? "text-right" : c._align === "center" ? "text-center" : undefined,
|
|
636
|
+
render: (row: Record<string, unknown>) => this._relCell(c, row),
|
|
637
|
+
}));
|
|
638
|
+
// Pivot columns (read from `row.pivot` when present).
|
|
639
|
+
for (const pc of rel._pivotColumns) {
|
|
640
|
+
tableColumns.push({
|
|
641
|
+
key: `pivot.${pc.key}`,
|
|
642
|
+
label: pc.label ?? titleCasePivot(pc.key),
|
|
643
|
+
render: (row: Record<string, unknown>) => {
|
|
644
|
+
const pivot = (row["pivot"] as Record<string, unknown> | undefined) ?? row;
|
|
645
|
+
const v = pivot?.[pc.key];
|
|
646
|
+
return v == null ? "—" : String(v);
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
tableColumns.push({
|
|
651
|
+
key: "__actions",
|
|
652
|
+
label: "",
|
|
653
|
+
class: "w-1 whitespace-nowrap text-right",
|
|
654
|
+
render: (row: Record<string, unknown>) => {
|
|
655
|
+
const id = String(row[related.primaryKey]);
|
|
656
|
+
return (
|
|
657
|
+
<div class="flex items-center justify-end gap-1">
|
|
658
|
+
<a
|
|
659
|
+
href={`${relBase}/${id}`}
|
|
660
|
+
navigate
|
|
661
|
+
title="View"
|
|
662
|
+
class={`${iconBtn} hover:bg-accent hover:text-accent-foreground`}
|
|
663
|
+
>
|
|
664
|
+
<Icon name="eye" class="h-4 w-4" />
|
|
665
|
+
</a>
|
|
666
|
+
{rel._canAttach ? (
|
|
667
|
+
<button
|
|
668
|
+
type="button"
|
|
669
|
+
onClick={this.detachRelated}
|
|
670
|
+
data-args={JSON.stringify([rel._relationName, id])}
|
|
671
|
+
confirm="Detach this record?"
|
|
672
|
+
title="Detach"
|
|
673
|
+
class={`${iconBtn} hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive`}
|
|
674
|
+
>
|
|
675
|
+
<Icon name="x-circle" class="h-4 w-4" />
|
|
676
|
+
</button>
|
|
677
|
+
) : null}
|
|
678
|
+
</div>
|
|
679
|
+
);
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const relName = rel._relationName ?? "";
|
|
684
|
+
|
|
685
|
+
return (
|
|
686
|
+
<div class="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
|
687
|
+
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-3">
|
|
688
|
+
<h2 class="flex items-center gap-2 text-sm font-semibold tracking-tight">
|
|
689
|
+
{rel._icon ? <Icon name={rel._icon} class="h-4 w-4 text-muted-foreground" /> : null}
|
|
690
|
+
{rel.getTitle()}
|
|
691
|
+
<span class="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
|
692
|
+
{rows.length}
|
|
693
|
+
</span>
|
|
694
|
+
</h2>
|
|
695
|
+
{rel._canAttach && options.length > 0 ? (
|
|
696
|
+
<div class="flex items-center gap-2">
|
|
697
|
+
<select
|
|
698
|
+
value={this.attachDraft[relName]}
|
|
699
|
+
class="h-8 rounded-lg border border-input bg-background px-2 text-sm outline-none transition focus:ring-2 focus:ring-ring"
|
|
700
|
+
>
|
|
701
|
+
<option value="">Attach {related.getLabel().toLowerCase()}…</option>
|
|
702
|
+
{options.map((o) => (
|
|
703
|
+
<option value={String(o[related.primaryKey])}>{related.recordTitle(o)}</option>
|
|
704
|
+
))}
|
|
705
|
+
</select>
|
|
706
|
+
<button
|
|
707
|
+
type="button"
|
|
708
|
+
onClick={this.attachRelated}
|
|
709
|
+
data-args={JSON.stringify([relName])}
|
|
710
|
+
class="inline-flex h-8 items-center gap-1 rounded-lg border border-input bg-background px-3 text-xs font-medium transition hover:bg-accent hover:text-accent-foreground"
|
|
711
|
+
>
|
|
712
|
+
<Icon name="plus" class="h-3.5 w-3.5" /> Attach
|
|
713
|
+
</button>
|
|
714
|
+
</div>
|
|
715
|
+
) : null}
|
|
716
|
+
</div>
|
|
717
|
+
<div class="overflow-x-auto p-1.5">
|
|
718
|
+
{rows.length > 0 ? (
|
|
719
|
+
<Table columns={tableColumns} rows={rows} hover />
|
|
720
|
+
) : (
|
|
721
|
+
<p class="px-4 py-8 text-center text-sm text-muted-foreground">
|
|
722
|
+
No {related.getPluralLabel().toLowerCase()} attached yet.
|
|
723
|
+
</p>
|
|
724
|
+
)}
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
override async render(): Promise<HtmlNode> {
|
|
731
|
+
const R = this._resource;
|
|
732
|
+
const base = this._panel.base();
|
|
733
|
+
const listHref = `${base}/${R.getSlug()}`;
|
|
734
|
+
const record = await R.find(this.recordId);
|
|
735
|
+
|
|
736
|
+
if (!record) {
|
|
737
|
+
return (
|
|
738
|
+
<div class="mx-auto w-full max-w-3xl">
|
|
739
|
+
<div class="rounded-xl border border-dashed border-border p-12 text-center">
|
|
740
|
+
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
741
|
+
<Icon name="collection" class="h-6 w-6" />
|
|
742
|
+
</div>
|
|
743
|
+
<p class="mt-3 text-sm font-medium">Record not found</p>
|
|
744
|
+
<a
|
|
745
|
+
href={listHref}
|
|
746
|
+
navigate
|
|
747
|
+
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary"
|
|
748
|
+
>
|
|
749
|
+
<Icon name="chevron-left" class="h-4 w-4" /> Back to{" "}
|
|
750
|
+
{R.getPluralLabel().toLowerCase()}
|
|
751
|
+
</a>
|
|
752
|
+
</div>
|
|
753
|
+
</div>
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const sections = resolveInfolist(R.infolist(), R.columns());
|
|
758
|
+
// Resolved here rather than inside the card so a failing audit query
|
|
759
|
+
// can't take the record page down with it.
|
|
760
|
+
const historyCard = this._historyCard(await this._history());
|
|
761
|
+
const title = String(record["name"] ?? record["title"] ?? record[R.primaryKey] ?? "Record");
|
|
762
|
+
|
|
763
|
+
// Load each relation manager's records. HasMany scopes children by foreign key;
|
|
764
|
+
// BelongsToMany resolves the attached rows (+ unattached options) via the pivot.
|
|
765
|
+
const parentId = record[R.primaryKey];
|
|
766
|
+
const relations = await Promise.all(
|
|
767
|
+
R.relations().map(async (rel) => {
|
|
768
|
+
if (rel.isBelongsToMany()) {
|
|
769
|
+
const rows = await this._attachedRows(rel._relationName ?? "");
|
|
770
|
+
const attachedIds = new Set(rows.map((r) => String(r[rel._resource.primaryKey])));
|
|
771
|
+
const pool = await rel._resource.records({ perPage: rel._attachLimit });
|
|
772
|
+
const options = pool.rows.filter(
|
|
773
|
+
(r) => !attachedIds.has(String(r[rel._resource.primaryKey])),
|
|
774
|
+
);
|
|
775
|
+
return { rel, kind: "btm" as const, rows, options };
|
|
776
|
+
}
|
|
777
|
+
const result = await rel._resource.records({
|
|
778
|
+
perPage: rel._perPage,
|
|
779
|
+
modifyQuery: (q) => q.where(rel._foreignKey, parentId),
|
|
780
|
+
});
|
|
781
|
+
return { rel, kind: "hasMany" as const, result };
|
|
782
|
+
}),
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
const sectionCard = (entries: HtmlNode[], columns: number): HtmlNode => (
|
|
786
|
+
<dl class={`grid gap-x-6 gap-y-5 ${COLS_CLASS[Math.min(columns, 4)]}`}>{entries}</dl>
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
return (
|
|
790
|
+
<div class="mx-auto w-full max-w-4xl space-y-6">
|
|
791
|
+
{/* Header */}
|
|
792
|
+
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
793
|
+
<div>
|
|
794
|
+
<Breadcrumbs
|
|
795
|
+
trail={resourceTrail({
|
|
796
|
+
panel: this._panel,
|
|
797
|
+
resource: R,
|
|
798
|
+
parentId: this.parentId || undefined,
|
|
799
|
+
recordId: this.recordId,
|
|
800
|
+
recordTitle: title,
|
|
801
|
+
})}
|
|
802
|
+
/>
|
|
803
|
+
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
|
|
804
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
805
|
+
{R.getLabel()} #{String(record[R.primaryKey] ?? "")}
|
|
806
|
+
</p>
|
|
807
|
+
</div>
|
|
808
|
+
<div class="flex items-center gap-2">
|
|
809
|
+
<a
|
|
810
|
+
href={listHref}
|
|
811
|
+
navigate
|
|
812
|
+
class="inline-flex h-9 items-center gap-1 rounded-lg border border-input bg-background px-3 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
|
|
813
|
+
>
|
|
814
|
+
<Icon name="chevron-left" class="h-4 w-4" /> Back
|
|
815
|
+
</a>
|
|
816
|
+
{(() => {
|
|
817
|
+
const trashed =
|
|
818
|
+
R.usesSoftDeletes() &&
|
|
819
|
+
(typeof (record as { trashed?: () => boolean }).trashed === "function"
|
|
820
|
+
? (record as { trashed: () => boolean }).trashed()
|
|
821
|
+
: record["deleted_at"] != null);
|
|
822
|
+
const ctx = this._ctx(record);
|
|
823
|
+
if (trashed) {
|
|
824
|
+
return [restoreAction(), forceDeleteAction()].map((a) =>
|
|
825
|
+
renderAction(a, ctx, { onRun: this.runAction, args: [a._key] }),
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
// "View" is what this page already is, so it's dropped from its own header.
|
|
829
|
+
return R.recordActions().map((a) =>
|
|
830
|
+
a instanceof ActionGroup
|
|
831
|
+
? renderActionGroup(a, ctx, {
|
|
832
|
+
onRun: this.runAction,
|
|
833
|
+
argsFor: (member) => [member._key],
|
|
834
|
+
})
|
|
835
|
+
: a._key === "view"
|
|
836
|
+
? null
|
|
837
|
+
: renderAction(a, ctx, { onRun: this.runAction, args: [a._key] }),
|
|
838
|
+
);
|
|
839
|
+
})()}
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
|
|
843
|
+
{resolveRenderHooks(this._panel.renderHooks("record.start"), {
|
|
844
|
+
resource: R.getSlug(),
|
|
845
|
+
page: "record",
|
|
846
|
+
recordId: this.recordId,
|
|
847
|
+
})}
|
|
848
|
+
|
|
849
|
+
{/* Infolist sections */}
|
|
850
|
+
{sections.map((s) => {
|
|
851
|
+
const entries = s._entries.map((e) => this._entryBlock(e.display(record)));
|
|
852
|
+
const header =
|
|
853
|
+
s._heading || s._description ? (
|
|
854
|
+
<div class="mb-4 flex items-start gap-2">
|
|
855
|
+
{s._icon ? (
|
|
856
|
+
<span class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
857
|
+
<Icon name={s._icon} class="h-4 w-4" />
|
|
858
|
+
</span>
|
|
859
|
+
) : null}
|
|
860
|
+
<div>
|
|
861
|
+
{s._heading ? (
|
|
862
|
+
<h2 class="text-sm font-semibold tracking-tight">{s._heading}</h2>
|
|
863
|
+
) : null}
|
|
864
|
+
{s._description ? (
|
|
865
|
+
<p class="text-xs text-muted-foreground">{s._description}</p>
|
|
866
|
+
) : null}
|
|
867
|
+
</div>
|
|
868
|
+
</div>
|
|
869
|
+
) : null;
|
|
870
|
+
|
|
871
|
+
const cardClass =
|
|
872
|
+
"rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm sm:p-6";
|
|
873
|
+
|
|
874
|
+
if (s._collapsible) {
|
|
875
|
+
return (
|
|
876
|
+
<details class={cardClass} open={!s._collapsed}>
|
|
877
|
+
<summary class="flex cursor-pointer list-none items-center justify-between gap-2 [&::-webkit-details-marker]:hidden">
|
|
878
|
+
<div class="flex-1">
|
|
879
|
+
{header ?? <span class="text-sm font-semibold">Details</span>}
|
|
880
|
+
</div>
|
|
881
|
+
<Icon
|
|
882
|
+
name="chevron-down"
|
|
883
|
+
class="h-4 w-4 text-muted-foreground transition group-open:rotate-180"
|
|
884
|
+
/>
|
|
885
|
+
</summary>
|
|
886
|
+
<div class="mt-4">{sectionCard(entries, s._columns)}</div>
|
|
887
|
+
</details>
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return (
|
|
892
|
+
<div class={cardClass}>
|
|
893
|
+
{header}
|
|
894
|
+
{sectionCard(entries, s._columns)}
|
|
895
|
+
</div>
|
|
896
|
+
);
|
|
897
|
+
})}
|
|
898
|
+
|
|
899
|
+
{/* Relation managers (HasMany children + BelongsToMany pivots) */}
|
|
900
|
+
{relations.map((r) =>
|
|
901
|
+
r.kind === "btm"
|
|
902
|
+
? this._btmCard(r.rel, r.rows, r.options)
|
|
903
|
+
: this._relationCard(r.rel, r.result, record[R.primaryKey]),
|
|
904
|
+
)}
|
|
905
|
+
|
|
906
|
+
{/* What happened to this record, and putting it back. */}
|
|
907
|
+
{historyCard}
|
|
908
|
+
|
|
909
|
+
{resolveRenderHooks(this._panel.renderHooks("record.end"), {
|
|
910
|
+
resource: R.getSlug(),
|
|
911
|
+
page: "record",
|
|
912
|
+
recordId: this.recordId,
|
|
913
|
+
})}
|
|
914
|
+
</div>
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Build a uniquely-named View page subclass bound to a resource — mirrors
|
|
921
|
+
* {@link makeResourceListPage} so Flow keeps component identity stable.
|
|
922
|
+
*/
|
|
923
|
+
export function makeRecordViewPage(
|
|
924
|
+
resource: ResourceClass,
|
|
925
|
+
panel: PanelInstance = Panel.default(),
|
|
926
|
+
): typeof RecordViewPage {
|
|
927
|
+
const Page = class extends RecordViewPage {
|
|
928
|
+
static override resource = resource;
|
|
929
|
+
static override panel = panel;
|
|
930
|
+
static override layout = makeAdminLayout(panel);
|
|
931
|
+
};
|
|
932
|
+
Object.defineProperty(Page, "name", { value: `${resource.getModelName()}ViewPage` });
|
|
933
|
+
return Page;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Render a history value compactly — objects as JSON, empties as a dash. */
|
|
937
|
+
function stringifyValue(value: unknown): string {
|
|
938
|
+
if (value === null || value === undefined || value === "") return "—";
|
|
939
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
940
|
+
const text = String(value);
|
|
941
|
+
return text.length > 60 ? `${text.slice(0, 60)}…` : text;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Title-case a pivot column key for its default header label. */
|
|
945
|
+
function titleCasePivot(key: string): string {
|
|
946
|
+
return key
|
|
947
|
+
.replace(/[_-]+/g, " ")
|
|
948
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
949
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
950
|
+
.trim();
|
|
951
|
+
}
|