@zerotal/admin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/package.json +78 -0
  5. package/src/Cluster.ts +50 -0
  6. package/src/Panel.ts +288 -0
  7. package/src/PanelInstance.ts +644 -0
  8. package/src/Resource.ts +918 -0
  9. package/src/actions/Action.ts +607 -0
  10. package/src/actions/ImportRecordsJob.ts +108 -0
  11. package/src/actions/csv.ts +123 -0
  12. package/src/actions/index.ts +39 -0
  13. package/src/actions/render.tsx +181 -0
  14. package/src/actions/transfer.ts +307 -0
  15. package/src/actions/xlsx.ts +304 -0
  16. package/src/auth/AuthLayout.tsx +34 -0
  17. package/src/auth/index.ts +13 -0
  18. package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
  19. package/src/auth/pages/LoginPage.tsx +121 -0
  20. package/src/auth/pages/ProfilePage.tsx +216 -0
  21. package/src/auth/pages/ResetPasswordPage.tsx +103 -0
  22. package/src/auth/pages/VerifyEmailPage.tsx +68 -0
  23. package/src/auth/register.ts +44 -0
  24. package/src/authRoles.ts +141 -0
  25. package/src/commands/MakeAdminResourceCommand.ts +181 -0
  26. package/src/config.ts +128 -0
  27. package/src/dashboardLayout.ts +101 -0
  28. package/src/databaseMedia.ts +148 -0
  29. package/src/databaseNotifications.ts +169 -0
  30. package/src/form/Field.ts +928 -0
  31. package/src/form/ResourceForm.ts +48 -0
  32. package/src/form/Section.ts +364 -0
  33. package/src/form/editors.ts +43 -0
  34. package/src/form/index.ts +59 -0
  35. package/src/history.ts +151 -0
  36. package/src/impersonation.ts +126 -0
  37. package/src/index.ts +380 -0
  38. package/src/infolist/Entry.ts +537 -0
  39. package/src/infolist/Section.ts +99 -0
  40. package/src/infolist/index.ts +38 -0
  41. package/src/media.ts +297 -0
  42. package/src/notifications.ts +65 -0
  43. package/src/pages/AdminPage.ts +100 -0
  44. package/src/pages/ConsolePage.tsx +324 -0
  45. package/src/pages/DashboardPage.tsx +264 -0
  46. package/src/pages/MediaPage.tsx +346 -0
  47. package/src/pages/NotificationsPage.tsx +155 -0
  48. package/src/pages/RecordViewPage.tsx +951 -0
  49. package/src/pages/ResourceFormPage.tsx +1856 -0
  50. package/src/pages/ResourceListPage.tsx +2552 -0
  51. package/src/pages/RolesPage.tsx +325 -0
  52. package/src/pages/SearchPage.tsx +169 -0
  53. package/src/plugin.ts +283 -0
  54. package/src/provider/AdminAbilityMiddleware.ts +25 -0
  55. package/src/provider/AdminGuardMiddleware.ts +29 -0
  56. package/src/provider/AdminProvider.ts +334 -0
  57. package/src/relations/RelationManager.ts +114 -0
  58. package/src/renderHooks.ts +86 -0
  59. package/src/roles.ts +175 -0
  60. package/src/savedViews.ts +79 -0
  61. package/src/support/ability.ts +73 -0
  62. package/src/support/authorize.ts +105 -0
  63. package/src/support/countCache.ts +37 -0
  64. package/src/support/hostPage.ts +30 -0
  65. package/src/table/Column.ts +353 -0
  66. package/src/table/Constraint.ts +238 -0
  67. package/src/table/Filter.ts +275 -0
  68. package/src/table/Group.ts +73 -0
  69. package/src/table/Tab.ts +77 -0
  70. package/src/testing.ts +121 -0
  71. package/src/theme.ts +70 -0
  72. package/src/ui/AdminLayout.tsx +355 -0
  73. package/src/ui/Breadcrumbs.tsx +84 -0
  74. package/src/ui/environmentIndicator.tsx +63 -0
  75. package/src/ui/icons.tsx +124 -0
  76. package/src/widgets/Widget.ts +251 -0
  77. package/src/widgets/render.tsx +154 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The queued half of {@link importAction}.
3
+ *
4
+ * A synchronous import holds a WebSocket round-trip open, so a large file looks
5
+ * like a hang and times out half-written. Handing the work to a queue turns that
6
+ * into a job the worker chews through while the user gets on with their day.
7
+ *
8
+ * This module imports `@zerotal/queue` lazily and only when an app actually
9
+ * queues an import, so the dependency stays optional for everyone else.
10
+ *
11
+ * The worker needs to be able to reconstruct the job from its payload, which
12
+ * means registering the class where the worker can see it:
13
+ *
14
+ * import { JobRegistry } from "@zerotal/queue";
15
+ * import { ImportRecordsJob } from "@zerotal/admin";
16
+ *
17
+ * JobRegistry.register(ImportRecordsJob);
18
+ *
19
+ * A job carries the panel id and resource slug rather than the resource itself,
20
+ * because a class cannot be serialised into a queue — the worker looks it up in
21
+ * the same registry the panel built at boot.
22
+ */
23
+ import { Panel } from "../Panel.ts";
24
+ import { importCsv } from "./transfer.ts";
25
+ import type { ImportResult } from "./transfer.ts";
26
+ import { frameworkLog } from "@zerotal/core/logger";
27
+
28
+ /** What the job needs to do its work, once it is pulled off the queue. */
29
+ export interface ImportRecordsPayload {
30
+ panelId: string;
31
+ slug: string;
32
+ csv: string;
33
+ mapping?: Record<number, string> | undefined;
34
+ }
35
+
36
+ /**
37
+ * A queued CSV import.
38
+ *
39
+ * Deliberately shaped like a `@zerotal/queue` `Job` — `handle()` plus
40
+ * `payload()` — without extending it, so `@zerotal/admin` needs no dependency
41
+ * on the queue package. Registering it works the same either way.
42
+ */
43
+ export class ImportRecordsJob {
44
+ readonly queue = "default";
45
+ readonly maxAttempts = 1; // A half-applied retry would double-import rows.
46
+
47
+ constructor(private readonly _payload: ImportRecordsPayload) {}
48
+
49
+ payload(): Record<string, unknown> {
50
+ return { ...this._payload };
51
+ }
52
+
53
+ get className(): string {
54
+ return "ImportRecordsJob";
55
+ }
56
+
57
+ async handle(): Promise<void> {
58
+ const result = await runQueuedImport(this._payload);
59
+ const log = frameworkLog("admin");
60
+ if (result.failures.length > 0) {
61
+ log.warn(`Import finished with ${result.failures.length} skipped row(s)`, {
62
+ slug: this._payload.slug,
63
+ created: result.created,
64
+ failures: result.failures.slice(0, 10),
65
+ });
66
+ } else {
67
+ log.info(`Imported ${result.created} record(s)`, { slug: this._payload.slug });
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Run an import described by a payload. Exported so an app can drive it from its
74
+ * own job class without adopting {@link ImportRecordsJob}.
75
+ */
76
+ export async function runQueuedImport(payload: ImportRecordsPayload): Promise<ImportResult> {
77
+ const panel = Panel.get(payload.panelId) ?? Panel.default();
78
+ const resource = panel.find(payload.slug);
79
+ if (!resource) {
80
+ return {
81
+ created: 0,
82
+ failures: [`No resource "${payload.slug}" on panel "${payload.panelId}".`],
83
+ };
84
+ }
85
+ // The row cap is a guard against holding a request open; a worker has no such
86
+ // constraint, so a queued import is allowed the whole file.
87
+ return importCsv(resource, payload.csv, payload.mapping, { limit: Number.POSITIVE_INFINITY });
88
+ }
89
+
90
+ /**
91
+ * Push an import onto the queue, returning false when no queue is available so
92
+ * the caller can fall back to importing inline.
93
+ */
94
+ export async function dispatchImport(payload: ImportRecordsPayload): Promise<boolean> {
95
+ try {
96
+ // Resolved by name so `@zerotal/queue` stays a genuinely optional peer —
97
+ // a static import would make every admin install depend on it.
98
+ const mod = (await import(/* @vite-ignore */ "@zerotal/queue" as string)) as {
99
+ Bus?: { dispatch?: (job: unknown) => Promise<unknown> };
100
+ };
101
+ if (typeof mod.Bus?.dispatch !== "function") return false;
102
+ await mod.Bus.dispatch(new ImportRecordsJob(payload));
103
+ return true;
104
+ } catch {
105
+ // No queue package, or no driver configured — the caller imports inline.
106
+ return false;
107
+ }
108
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * CSV encoding and decoding for the import/export actions.
3
+ *
4
+ * Deliberately small and dependency-free, but strict about the two things that
5
+ * actually bite: quoting on the way out (a comma, quote or newline inside a
6
+ * value must not shift every later column) and quote handling on the way in.
7
+ */
8
+ import type { Column } from "../table/Column.ts";
9
+
10
+ /** Quote a field when it contains anything that would otherwise break the row. */
11
+ function encodeField(value: string): string {
12
+ if (!/[",\r\n]/.test(value)) return value;
13
+ return `"${value.replace(/"/g, '""')}"`;
14
+ }
15
+
16
+ /**
17
+ * Render a value for a CSV cell.
18
+ *
19
+ * Primitives go out as themselves — a date export should be re-importable, not
20
+ * "3 days ago". Anything structured (a loaded relation, a JSON column) falls
21
+ * back to the column's display text, which is the only meaningful flat form.
22
+ */
23
+ function encodeValue(column: Column, row: Record<string, unknown>): string {
24
+ const raw = column.raw(row);
25
+ if (raw == null) return "";
26
+ if (typeof raw === "string") return raw;
27
+ if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint") {
28
+ return String(raw);
29
+ }
30
+ if (raw instanceof Date) return raw.toISOString();
31
+ return column.cell(row).text;
32
+ }
33
+
34
+ /** Serialise rows to CSV with a header line, one column per exportable column. */
35
+ export function toCsv(rows: Record<string, unknown>[], columns: Column[]): string {
36
+ const header = columns.map((c) => encodeField(c.getLabel())).join(",");
37
+ const body = rows.map((row) => columns.map((c) => encodeField(encodeValue(c, row))).join(","));
38
+ return [header, ...body].join("\r\n");
39
+ }
40
+
41
+ /**
42
+ * Parse CSV into rows of raw strings, the first row being the header.
43
+ *
44
+ * Handles quoted fields containing commas, escaped quotes (`""`) and embedded
45
+ * newlines. Returns an empty array for empty input rather than a phantom row.
46
+ */
47
+ export function parseCsv(text: string): string[][] {
48
+ const source = text.replace(/^/, ""); // strip a spreadsheet's byte-order mark
49
+ if (source.trim() === "") return [];
50
+
51
+ const rows: string[][] = [];
52
+ let row: string[] = [];
53
+ let field = "";
54
+ let quoted = false;
55
+
56
+ for (let i = 0; i < source.length; i++) {
57
+ const char = source[i]!;
58
+
59
+ if (quoted) {
60
+ if (char !== '"') {
61
+ field += char;
62
+ continue;
63
+ }
64
+ // A doubled quote is a literal one; a lone quote closes the field.
65
+ if (source[i + 1] === '"') {
66
+ field += '"';
67
+ i++;
68
+ } else {
69
+ quoted = false;
70
+ }
71
+ continue;
72
+ }
73
+
74
+ if (char === '"' && field === "") {
75
+ quoted = true;
76
+ } else if (char === ",") {
77
+ row.push(field);
78
+ field = "";
79
+ } else if (char === "\r") {
80
+ // Swallow it; the \n that follows ends the row.
81
+ } else if (char === "\n") {
82
+ row.push(field);
83
+ rows.push(row);
84
+ row = [];
85
+ field = "";
86
+ } else {
87
+ field += char;
88
+ }
89
+ }
90
+
91
+ // A file not ending in a newline still has one row left in hand.
92
+ if (field !== "" || row.length > 0) {
93
+ row.push(field);
94
+ rows.push(row);
95
+ }
96
+ return rows;
97
+ }
98
+
99
+ /**
100
+ * Pair a CSV's header cells with resource fields.
101
+ *
102
+ * Matching is forgiving about the ways a label and a key differ in practice —
103
+ * case, spaces, underscores, hyphens — so a file exported from the panel
104
+ * re-imports without the user mapping a single column by hand.
105
+ */
106
+ export function guessColumnMapping(
107
+ headers: string[],
108
+ candidates: { key: string; label: string }[],
109
+ ): Record<number, string> {
110
+ const normalize = (s: string): string => s.toLowerCase().replace(/[\s_-]+/g, "");
111
+ const byKey = new Map<string, string>();
112
+ for (const c of candidates) {
113
+ byKey.set(normalize(c.key), c.key);
114
+ byKey.set(normalize(c.label), c.key);
115
+ }
116
+
117
+ const mapping: Record<number, string> = {};
118
+ headers.forEach((header, i) => {
119
+ const match = byKey.get(normalize(header));
120
+ if (match) mapping[i] = match;
121
+ });
122
+ return mapping;
123
+ }
@@ -0,0 +1,39 @@
1
+ export {
2
+ Action,
3
+ ActionGroup,
4
+ action,
5
+ actionGroup,
6
+ flattenActions,
7
+ viewAction,
8
+ editAction,
9
+ deleteAction,
10
+ createAction,
11
+ replicateAction,
12
+ impersonateAction,
13
+ bulkEditAction,
14
+ bulkDeleteAction,
15
+ restoreAction,
16
+ forceDeleteAction,
17
+ bulkRestoreAction,
18
+ bulkForceDeleteAction,
19
+ } from "./Action.ts";
20
+ export type {
21
+ ActionColor,
22
+ ActionContext,
23
+ ActionHandler,
24
+ ActionItem,
25
+ ActionPage,
26
+ ActionVisible,
27
+ } from "./Action.ts";
28
+ export { renderAction, renderActionGroup, renderActionMenuItem } from "./render.tsx";
29
+ export type { RenderActionOptions } from "./render.tsx";
30
+ export {
31
+ exportAction,
32
+ bulkExportAction,
33
+ importAction,
34
+ importCsv,
35
+ IMPORT_ROW_LIMIT,
36
+ MAPPING_FIELD_PREFIX,
37
+ } from "./transfer.ts";
38
+ export type { ImportResult } from "./transfer.ts";
39
+ export { toCsv, parseCsv, guessColumnMapping } from "./csv.ts";
@@ -0,0 +1,181 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // Render an Action as a themed link (navigate) or server-action button (with an
3
+ // optional `confirm` dialog). Shared by row actions, header actions, and the
4
+ // bulk toolbar so every action looks and behaves consistently.
5
+
6
+ import type { HtmlNode } from "@zerotal/flow";
7
+ import { DropdownMenu } from "@zerotal/flow-ui";
8
+ import { Icon } from "../ui/icons.tsx";
9
+ import type { Action, ActionColor, ActionContext, ActionGroup } from "./Action.ts";
10
+
11
+ const COLOR: Record<ActionColor, string> = {
12
+ default:
13
+ "border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
14
+ primary: "border-transparent bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm",
15
+ success: "border-input bg-background text-success hover:bg-success/10 hover:border-success/40",
16
+ muted: "border-input bg-background text-muted-foreground hover:bg-accent",
17
+ destructive:
18
+ "border-input bg-background text-muted-foreground hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
19
+ };
20
+
21
+ export interface RenderActionOptions {
22
+ /** The page's @expose handler invoked for callback actions. */
23
+ onRun?: unknown;
24
+ /** The page's @expose handler invoked for actions with a modal form (opens it). */
25
+ onForm?: unknown;
26
+ /** Args serialized into `data-args` for the handler (e.g. [key, id]). */
27
+ args?: unknown[];
28
+ }
29
+
30
+ /** Render a single action. Link actions become `<a navigate>`, callbacks `<button>`. */
31
+ export function renderAction(
32
+ a: Action,
33
+ ctx: ActionContext,
34
+ opts: RenderActionOptions = {},
35
+ ): HtmlNode | null {
36
+ if (!a.isVisibleFor(ctx.record, ctx)) return null;
37
+
38
+ const colorCls = COLOR[a._color];
39
+ const cls = a._iconOnly
40
+ ? `inline-flex h-8 w-8 items-center justify-center rounded-md border transition ${colorCls}`
41
+ : `inline-flex h-9 items-center gap-1.5 rounded-lg border px-3 text-sm font-medium transition ${colorCls}`;
42
+
43
+ const inner = (
44
+ <>
45
+ {a._icon ? <Icon name={a._icon} class="h-4 w-4" /> : null}
46
+ {a._iconOnly ? null : <span>{a.getLabel()}</span>}
47
+ </>
48
+ );
49
+
50
+ // Modal-form action: open the dialog (no confirm — the modal is the confirm).
51
+ if (a.hasForm() && opts.onForm) {
52
+ return (
53
+ <button
54
+ type="button"
55
+ onClick={opts.onForm}
56
+ data-args={JSON.stringify(opts.args ?? [])}
57
+ title={a.getLabel()}
58
+ class={cls}
59
+ >
60
+ {inner}
61
+ </button>
62
+ );
63
+ }
64
+
65
+ if (a.isLink()) {
66
+ const href = a.href(ctx) ?? "#";
67
+ return (
68
+ <a href={href} navigate title={a.getLabel()} class={cls}>
69
+ {inner}
70
+ </a>
71
+ );
72
+ }
73
+
74
+ // Callback action: server method + optional confirm dialog.
75
+ return (
76
+ <button
77
+ type="button"
78
+ onClick={opts.onRun}
79
+ data-args={JSON.stringify(opts.args ?? [])}
80
+ {...(a._confirm ? { confirm: a._confirm } : {})}
81
+ title={a.getLabel()}
82
+ class={cls}
83
+ >
84
+ {inner}
85
+ </button>
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Render a group as one trigger opening a menu of its members.
91
+ *
92
+ * Returns `null` when nothing inside is visible, so an empty menu never appears.
93
+ * `argsFor` builds each member's handler arguments, since those carry the
94
+ * action's own key.
95
+ */
96
+ export function renderActionGroup(
97
+ group: ActionGroup,
98
+ ctx: ActionContext,
99
+ opts: RenderActionOptions & { argsFor?: (a: Action) => unknown[] } = {},
100
+ ): HtmlNode | null {
101
+ const members = group.visibleActions(ctx.record, ctx);
102
+ if (members.length === 0) return null;
103
+
104
+ return (
105
+ <DropdownMenu
106
+ align="right"
107
+ trigger={
108
+ <button
109
+ type="button"
110
+ title={group.getLabel()}
111
+ class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
112
+ >
113
+ <Icon name={group._icon} class="h-4 w-4" />
114
+ </button>
115
+ }
116
+ >
117
+ {members.map((a) => {
118
+ const args = opts.argsFor ? opts.argsFor(a) : opts.args;
119
+ return renderActionMenuItem(a, ctx, {
120
+ ...opts,
121
+ ...(args === undefined ? {} : { args }),
122
+ });
123
+ })}
124
+ </DropdownMenu>
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Render an action as a full-width dropdown menu row (icon + label). Used by the
130
+ * row-action overflow menu when a row has more actions than fit inline.
131
+ */
132
+ export function renderActionMenuItem(
133
+ a: Action,
134
+ ctx: ActionContext,
135
+ opts: RenderActionOptions = {},
136
+ ): HtmlNode | null {
137
+ if (!a.isVisibleFor(ctx.record, ctx)) return null;
138
+
139
+ const tone =
140
+ a._color === "destructive"
141
+ ? "text-destructive hover:bg-destructive/10"
142
+ : "hover:bg-accent hover:text-accent-foreground";
143
+ const cls = `flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors ${tone}`;
144
+ const inner = (
145
+ <>
146
+ {a._icon ? <Icon name={a._icon} class="h-4 w-4" /> : null}
147
+ <span>{a.getLabel()}</span>
148
+ </>
149
+ );
150
+
151
+ if (a.hasForm() && opts.onForm) {
152
+ return (
153
+ <button
154
+ type="button"
155
+ onClick={opts.onForm}
156
+ data-args={JSON.stringify(opts.args ?? [])}
157
+ class={cls}
158
+ >
159
+ {inner}
160
+ </button>
161
+ );
162
+ }
163
+ if (a.isLink()) {
164
+ return (
165
+ <a href={a.href(ctx) ?? "#"} navigate class={cls}>
166
+ {inner}
167
+ </a>
168
+ );
169
+ }
170
+ return (
171
+ <button
172
+ type="button"
173
+ onClick={opts.onRun}
174
+ data-args={JSON.stringify(opts.args ?? [])}
175
+ {...(a._confirm ? { confirm: a._confirm } : {})}
176
+ class={cls}
177
+ >
178
+ {inner}
179
+ </button>
180
+ );
181
+ }