@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,275 @@
1
+ /**
2
+ * Table filters. A filter scopes the list query and
3
+ * its active value lives in the URL (`?filters=…`), so it composes cleanly with
4
+ * search, sort, tabs, and pagination — every one of which is URL-driven.
5
+ *
6
+ * selectFilter("status").options({ active: "Active", archived: "Archived" })
7
+ * ternaryFilter("verified")
8
+ * .label("Email verified")
9
+ * .query((q, v) => (v === "1" ? q.whereNotNull!("email_verified_at") : q.whereNull!("email_verified_at")))
10
+ */
11
+ import type { AdminQuery } from "../Resource.ts";
12
+ import type { Conjunction, Constraint } from "./Constraint.ts";
13
+
14
+ export type FilterType = "select" | "ternary" | "builder" | "text";
15
+
16
+ /**
17
+ * A node in a query-builder filter's rule tree: either one comparison, or a
18
+ * group combining several with `AND`/`OR`. Groups nest, so "status is paid AND
19
+ * (total > 100 OR customer contains acme)" is expressible.
20
+ */
21
+ export type QueryRule =
22
+ | { type: "rule"; constraint: string; operator: string; value?: string }
23
+ | { type: "group"; operator: Conjunction; rules: QueryRule[] };
24
+
25
+ /** Parse the JSON a query-builder filter stores in the URL. Invalid input filters nothing. */
26
+ export function parseRuleTree(value: string): QueryRule | null {
27
+ if (!value) return null;
28
+ try {
29
+ const parsed = JSON.parse(value) as unknown;
30
+ return isRule(parsed) ? parsed : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function isRule(node: unknown): node is QueryRule {
37
+ if (!node || typeof node !== "object") return false;
38
+ const n = node as Record<string, unknown>;
39
+ if (n["type"] === "rule")
40
+ return typeof n["constraint"] === "string" && typeof n["operator"] === "string";
41
+ if (n["type"] === "group") return Array.isArray(n["rules"]) && n["rules"].every(isRule);
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * A short human summary of a rule tree, for the active-filter chips.
47
+ *
48
+ * "Advanced filter: 3 rules" tells someone their list is narrowed and by roughly
49
+ * how much; the builder itself shows the detail. One rule gets named outright,
50
+ * since that is the common case and the label fits.
51
+ */
52
+ export function describeRuleTree(
53
+ node: QueryRule | null,
54
+ filter?: { _constraints: Constraint[] },
55
+ ): string {
56
+ if (!node) return "none";
57
+ const rules: Extract<QueryRule, { type: "rule" }>[] = [];
58
+ const walk = (n: QueryRule): void => {
59
+ if (n.type === "rule") rules.push(n);
60
+ else n.rules.forEach(walk);
61
+ };
62
+ walk(node);
63
+
64
+ if (rules.length === 0) return "none";
65
+ if (rules.length === 1) {
66
+ const rule = rules[0]!;
67
+ const constraint = filter?._constraints.find((c) => c._key === rule.constraint);
68
+ const label = constraint?.getLabel() ?? rule.constraint;
69
+ const operator =
70
+ constraint?.operators().find((o) => o.value === rule.operator)?.label ?? rule.operator;
71
+ return [label, operator, rule.value].filter(Boolean).join(" ");
72
+ }
73
+ return `${rules.length} rules`;
74
+ }
75
+
76
+ /** Does this tree actually constrain anything? An empty group is a no-op. */
77
+ export function ruleTreeIsEmpty(node: QueryRule | null): boolean {
78
+ if (!node) return true;
79
+ if (node.type === "rule") return false;
80
+ return node.rules.every(ruleTreeIsEmpty);
81
+ }
82
+
83
+ export interface FilterOption {
84
+ value: string;
85
+ label: string;
86
+ }
87
+
88
+ export type FilterApply = (query: AdminQuery, value: string) => AdminQuery;
89
+
90
+ export class Filter {
91
+ /** @internal */ _key: string;
92
+ /** @internal */ _label?: string;
93
+ /** @internal */ _type: FilterType;
94
+ /** @internal */ _column?: string;
95
+ /** @internal */ _options: FilterOption[] = [];
96
+ /** @internal */ _apply?: FilterApply;
97
+ /** @internal */ _trueLabel = "Yes";
98
+ /** @internal */ _falseLabel = "No";
99
+ /** @internal Available comparisons, for a query-builder filter. */ _constraints: Constraint[] =
100
+ [];
101
+
102
+ constructor(key: string, type: FilterType = "select") {
103
+ this._key = key;
104
+ this._type = type;
105
+ }
106
+
107
+ static make(key: string): Filter {
108
+ return new Filter(key);
109
+ }
110
+
111
+ label(label: string): this {
112
+ this._label = label;
113
+ return this;
114
+ }
115
+
116
+ /** Column to filter on (defaults to the filter key). */
117
+ column(column: string): this {
118
+ this._column = column;
119
+ return this;
120
+ }
121
+
122
+ /** Options for a select filter — `{value: label}` map or `{value,label}[]`. */
123
+ options(options: Record<string, string> | FilterOption[]): this {
124
+ this._options = Array.isArray(options)
125
+ ? options
126
+ : Object.entries(options).map(([value, label]) => ({ value, label }));
127
+ return this;
128
+ }
129
+
130
+ /** Labels for the two states of a ternary filter. */
131
+ labels(trueLabel: string, falseLabel: string): this {
132
+ this._trueLabel = trueLabel;
133
+ this._falseLabel = falseLabel;
134
+ return this;
135
+ }
136
+
137
+ /** Comparisons a query-builder filter offers. */
138
+ constraints(constraints: Constraint[]): this {
139
+ this._constraints = constraints;
140
+ return this;
141
+ }
142
+
143
+ /** Custom query scope; receives the raw active value. */
144
+ query(fn: FilterApply): this {
145
+ this._apply = fn;
146
+ return this;
147
+ }
148
+
149
+ getLabel(): string {
150
+ return this._label ?? titleCase(this._key);
151
+ }
152
+
153
+ /** The selectable options including the implicit "all"/ternary states. */
154
+ choices(): FilterOption[] {
155
+ if (this._type === "ternary") {
156
+ return [
157
+ { value: "1", label: this._trueLabel },
158
+ { value: "0", label: this._falseLabel },
159
+ ];
160
+ }
161
+ return this._options;
162
+ }
163
+
164
+ /** Apply this filter's scope for a chosen value. */
165
+ apply(query: AdminQuery, value: string): AdminQuery {
166
+ if (this._apply) return this._apply(query, value);
167
+ if (this._type === "builder") return this._applyRuleTree(query, value);
168
+ const col = this._column ?? this._key;
169
+ if (this._type === "ternary") return query.where(col, value === "1");
170
+ // A typed-in filter matches anywhere in the value: someone typing three
171
+ // letters into a header box is looking for a substring, not an exact row.
172
+ if (this._type === "text") return query.where(col, "like", `%${value}%`);
173
+ return query.where(col, value);
174
+ }
175
+
176
+ /**
177
+ * Apply a rule tree, wrapping the whole thing in one group.
178
+ *
179
+ * The wrapper matters: without it an `OR` at the top level would break out of
180
+ * whatever scope the list page already applied — a tab, a parent record, a
181
+ * soft-delete filter — and widen the result set past what the user is allowed
182
+ * to see. Grouping keeps the tree a single `AND`ed unit.
183
+ */
184
+ private _applyRuleTree(query: AdminQuery, value: string): AdminQuery {
185
+ const tree = parseRuleTree(value);
186
+ if (ruleTreeIsEmpty(tree)) return query;
187
+ const byKey = new Map(this._constraints.map((c) => [c._key, c]));
188
+ const group: Extract<QueryRule, { type: "group" }> =
189
+ tree!.type === "group" ? tree! : { type: "group", operator: "and", rules: [tree!] };
190
+
191
+ // Nothing survives validation — an unknown constraint, a rule still waiting
192
+ // for its value. Emitting the wrapper anyway would leave an empty `WHERE ()`.
193
+ if (!hasApplicableRule(group, byKey)) return query;
194
+
195
+ return query.where((sub: AdminQuery) => applyGroup(sub, group, byKey));
196
+ }
197
+ }
198
+
199
+ /** Whether any rule in this subtree names a known constraint and is complete. */
200
+ function hasApplicableRule(node: QueryRule, byKey: Map<string, Constraint>): boolean {
201
+ if (node.type === "group") return node.rules.some((r) => hasApplicableRule(r, byKey));
202
+ const constraint = byKey.get(node.constraint);
203
+ if (!constraint) return false;
204
+ return constraint.isUnary(node.operator) || Boolean(node.value);
205
+ }
206
+
207
+ /** Add a group's rules to `query`, each joined by the group's operator. */
208
+ function applyGroup(
209
+ query: AdminQuery,
210
+ group: Extract<QueryRule, { type: "group" }>,
211
+ byKey: Map<string, Constraint>,
212
+ ): void {
213
+ let first = true;
214
+ for (const rule of group.rules) {
215
+ if (ruleTreeIsEmpty(rule)) continue;
216
+ // The first predicate in a group always joins with AND — there is nothing
217
+ // yet for it to be an alternative to.
218
+ const conjunction: Conjunction = first ? "and" : group.operator;
219
+
220
+ if (rule.type === "group") {
221
+ // Same reasoning as the outer wrapper: skip a subgroup with nothing to say.
222
+ if (!hasApplicableRule(rule, byKey)) continue;
223
+ const nest = (conjunction === "or" ? query.orWhere : query.where) as
224
+ ((fn: (sub: AdminQuery) => void) => AdminQuery) | undefined;
225
+ if (typeof nest !== "function") continue;
226
+ nest.call(query, (sub: AdminQuery) => applyGroup(sub, rule, byKey));
227
+ } else {
228
+ const constraint = byKey.get(rule.constraint);
229
+ // An unknown constraint is a URL naming a column the resource never
230
+ // offered — drop it rather than filtering on attacker-chosen input.
231
+ if (!constraint) continue;
232
+ if (!constraint.isUnary(rule.operator) && !rule.value) continue;
233
+ constraint.apply(query, rule.operator, rule.value ?? "", conjunction);
234
+ }
235
+ first = false;
236
+ }
237
+ }
238
+
239
+ /** Single-choice dropdown filter. */
240
+ export function selectFilter(key: string): Filter {
241
+ return new Filter(key, "select");
242
+ }
243
+
244
+ /** Free-text filter, matching anywhere in the column. */
245
+ export function textFilter(key: string): Filter {
246
+ return new Filter(key, "text");
247
+ }
248
+
249
+ /** Three-state filter — all / yes / no. */
250
+ export function ternaryFilter(key: string): Filter {
251
+ return new Filter(key, "ternary");
252
+ }
253
+
254
+ /**
255
+ * A build-your-own filter: the user stacks comparisons and nests AND/OR groups
256
+ * rather than picking from fixed choices. Declare what may be compared with
257
+ * {@link Filter.constraints}.
258
+ *
259
+ * queryBuilder("q").constraints([
260
+ * textConstraint("name"),
261
+ * numberConstraint("total"),
262
+ * selectConstraint("status").options({ open: "Open", closed: "Closed" }),
263
+ * ])
264
+ */
265
+ export function queryBuilder(key: string): Filter {
266
+ return new Filter(key, "builder");
267
+ }
268
+
269
+ function titleCase(key: string): string {
270
+ return key
271
+ .replace(/[_-]+/g, " ")
272
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
273
+ .replace(/\b\w/g, (c) => c.toUpperCase())
274
+ .trim();
275
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Row grouping for the list table. A resource lists
3
+ * the groupings it supports via `static groups()`; the list page offers a
4
+ * "Group by" menu and renders a header row before each group's rows.
5
+ *
6
+ * static groups() {
7
+ * return [
8
+ * group("status"),
9
+ * group("created_at").label("Joined").getTitleUsing((r) => formatMonth(r.created_at)),
10
+ * ];
11
+ * }
12
+ * static defaultGroup = "status";
13
+ */
14
+ export class Group {
15
+ /** @internal */ _column: string;
16
+ /** @internal */ _label?: string;
17
+ /** @internal */ _getTitle?: (row: Record<string, unknown>) => string;
18
+ /** @internal */ _collapsible = false;
19
+
20
+ constructor(column: string) {
21
+ this._column = column;
22
+ }
23
+
24
+ static make(column: string): Group {
25
+ return new Group(column);
26
+ }
27
+
28
+ /** Heading label for the grouping (defaults to a title-cased column). */
29
+ label(label: string): this {
30
+ this._label = label;
31
+ return this;
32
+ }
33
+
34
+ /** Derive each group's title from a row (e.g. bucket a date into a month). */
35
+ getTitleUsing(fn: (row: Record<string, unknown>) => string): this {
36
+ this._getTitle = fn;
37
+ return this;
38
+ }
39
+
40
+ collapsible(value = true): this {
41
+ this._collapsible = value;
42
+ return this;
43
+ }
44
+
45
+ /** The grouping's column key (also its stable identifier). */
46
+ getColumn(): string {
47
+ return this._column;
48
+ }
49
+
50
+ getLabel(): string {
51
+ return this._label ?? titleCase(this._column);
52
+ }
53
+
54
+ /** The group title for a given row. */
55
+ titleFor(row: Record<string, unknown>): string {
56
+ if (this._getTitle) return this._getTitle(row);
57
+ const v = row[this._column];
58
+ return v === null || v === undefined || v === "" ? "—" : String(v);
59
+ }
60
+ }
61
+
62
+ /** Group the list's rows under a column's value. */
63
+ export function group(column: string): Group {
64
+ return new Group(column);
65
+ }
66
+
67
+ function titleCase(key: string): string {
68
+ return key
69
+ .replace(/[_-]+/g, " ")
70
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
71
+ .replace(/\b\w/g, (c) => c.toUpperCase())
72
+ .trim();
73
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * List-page tabs — filter presets shown above the table. Each tab scopes the
3
+ * query and can show a badge count.
4
+ *
5
+ * tab("all").label("All"),
6
+ * tab("verified").label("Verified").badge().modifyQuery((q) => q.whereNotNull("email_verified_at")),
7
+ * tab("unverified").label("Unverified").badge("!" ).badgeColor("warning")
8
+ * .modifyQuery((q) => q.whereNull("email_verified_at")),
9
+ */
10
+ import type { QueryModifier } from "../Resource.ts";
11
+ import type { BadgeTone } from "./Column.ts";
12
+
13
+ export class Tab {
14
+ /** @internal */ _key: string;
15
+ /** @internal */ _label?: string;
16
+ /** @internal */ _icon?: string;
17
+ /** @internal */ _badge = false;
18
+ /** @internal */ _badgeValue?: number | string;
19
+ /** @internal */ _badgeTone: BadgeTone = "muted";
20
+ /** @internal */ _modify?: QueryModifier;
21
+
22
+ constructor(key: string) {
23
+ this._key = key;
24
+ }
25
+
26
+ static make(key: string): Tab {
27
+ return new Tab(key);
28
+ }
29
+
30
+ label(label: string): this {
31
+ this._label = label;
32
+ return this;
33
+ }
34
+
35
+ icon(name: string): this {
36
+ this._icon = name;
37
+ return this;
38
+ }
39
+
40
+ /**
41
+ * Show a count badge. With no argument the panel counts matching records;
42
+ * pass a value to show a fixed badge instead.
43
+ */
44
+ badge(value?: number | string): this {
45
+ this._badge = true;
46
+ if (value !== undefined) this._badgeValue = value;
47
+ return this;
48
+ }
49
+
50
+ badgeColor(tone: BadgeTone): this {
51
+ this._badgeTone = tone;
52
+ return this;
53
+ }
54
+
55
+ /** Scope the table query for this tab. */
56
+ modifyQuery(fn: QueryModifier): this {
57
+ this._modify = fn;
58
+ return this;
59
+ }
60
+
61
+ getLabel(): string {
62
+ return this._label ?? titleCase(this._key);
63
+ }
64
+ }
65
+
66
+ /** A filter preset shown as a tab above the list. */
67
+ export function tab(key: string): Tab {
68
+ return new Tab(key);
69
+ }
70
+
71
+ function titleCase(key: string): string {
72
+ return key
73
+ .replace(/[_-]+/g, " ")
74
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
75
+ .replace(/\b\w/g, (c) => c.toUpperCase())
76
+ .trim();
77
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Admin testing helpers — thin, resource-aware wrappers over Flow's in-process
3
+ * test harness ({@link FlowTest}). They mount a resource's List / View / Form
4
+ * page without the route boilerplate, and add assertions phrased in admin terms
5
+ * (columns, records, actions, fields).
6
+ *
7
+ * @example
8
+ * import { AdminTest, assertHasColumn, assertHasAction } from "@zerotal/admin/testing";
9
+ *
10
+ * const t = await AdminTest.list(UserResource);
11
+ * assertHasColumn(t, UserResource, "email");
12
+ * assertHasAction(t, "Create");
13
+ * t.assertSee("ada@example.com");
14
+ *
15
+ * const form = await AdminTest.form(UserResource, "create");
16
+ * await form.set("form", { name: "" });
17
+ * await form.call("save");
18
+ * form.assertHasErrors("name");
19
+ */
20
+ import { FlowTest } from "@zerotal/flow/testing";
21
+ import type { Component } from "@zerotal/flow";
22
+ import type { ResourceClass } from "./Panel.ts";
23
+ import { Panel } from "./Panel.ts";
24
+ import type { PanelInstance } from "./PanelInstance.ts";
25
+ import type { FieldMode } from "./form/index.ts";
26
+ import { makeResourceForm, flattenFields } from "./form/index.ts";
27
+ import { makeResourceListPage } from "./pages/ResourceListPage.tsx";
28
+ import { makeRecordViewPage } from "./pages/RecordViewPage.tsx";
29
+ import { ResourceFormPage, registerResourceForm } from "./pages/ResourceFormPage.tsx";
30
+
31
+ /** A mounted Flow test for any admin page. */
32
+ export type AdminPageTest = FlowTest<Component>;
33
+
34
+ export const AdminTest = {
35
+ /** Mount a resource's List page. `props` seed `@url` state (search/sort/page/…). */
36
+ async list(
37
+ resource: ResourceClass,
38
+ props: Record<string, unknown> = {},
39
+ panel: PanelInstance = Panel.default(),
40
+ ): Promise<AdminPageTest> {
41
+ return FlowTest.mount(
42
+ makeResourceListPage(resource, panel) as unknown as new () => Component,
43
+ props as Partial<Component>,
44
+ );
45
+ },
46
+
47
+ /** Mount a resource's View page for a record id. */
48
+ async view(
49
+ resource: ResourceClass,
50
+ recordId: string | number,
51
+ panel: PanelInstance = Panel.default(),
52
+ ): Promise<AdminPageTest> {
53
+ return FlowTest.mount(
54
+ makeRecordViewPage(resource, panel) as unknown as new () => Component,
55
+ {
56
+ recordId: String(recordId),
57
+ } as Partial<Component>,
58
+ );
59
+ },
60
+
61
+ /**
62
+ * Mount a resource's Create/Edit form page. Registers the resource's form
63
+ * config (as the provider does) so the shared page can resolve it by slug.
64
+ */
65
+ async form(
66
+ resource: ResourceClass,
67
+ mode: FieldMode = "create",
68
+ props: Record<string, unknown> = {},
69
+ panel: PanelInstance = Panel.default(),
70
+ ): Promise<AdminPageTest> {
71
+ const slug = resource.getSlug();
72
+ const model = resource.getModelName();
73
+ const fields = resource.form();
74
+ const create = makeResourceForm(fields, "create", `${model}CreateTestForm`);
75
+ const edit = makeResourceForm(fields, "edit", `${model}EditTestForm`);
76
+ registerResourceForm(panel.id, slug, {
77
+ resource,
78
+ create: { FormClass: create.FormClass, fields: create.fields },
79
+ edit: { FormClass: edit.FormClass, fields: edit.fields },
80
+ });
81
+ return FlowTest.mount(
82
+ ResourceFormPage as unknown as new () => Component,
83
+ {
84
+ slug,
85
+ mode,
86
+ panelId: panel.id,
87
+ ...props,
88
+ } as Partial<Component>,
89
+ );
90
+ },
91
+ };
92
+
93
+ // ── Resource-aware assertions ───────────────────────────────────────────────
94
+
95
+ /** Assert a column's header is rendered (by key → its resolved label). */
96
+ export function assertHasColumn(t: AdminPageTest, resource: ResourceClass, key: string): void {
97
+ const col = resource.columns().find((c) => c._key === key);
98
+ t.assertSee(col ? col.getLabel() : key);
99
+ }
100
+
101
+ /** Assert an action label is present on the page. */
102
+ export function assertHasAction(t: AdminPageTest, label: string): void {
103
+ t.assertSee(label);
104
+ }
105
+
106
+ /** Assert a form field's label is rendered (by key → its resolved label). */
107
+ export function assertHasField(t: AdminPageTest, resource: ResourceClass, key: string): void {
108
+ const field = flattenFields(resource.form()).find((f) => f._key === key);
109
+ t.assertSee(field ? field.getLabel() : key);
110
+ }
111
+
112
+ /** Assert the rendered HTML contains a record's title (a row is present). */
113
+ export function assertSeesRecord(
114
+ t: AdminPageTest,
115
+ resource: ResourceClass,
116
+ record: Record<string, unknown>,
117
+ ): void {
118
+ t.assertSee(resource.recordTitle(record));
119
+ }
120
+
121
+ export { FlowTest };
package/src/theme.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Admin theme — the `<head>` payload that makes the panel look good in both
3
+ * light and dark mode with zero build step.
4
+ *
5
+ * The tokens themselves live in `@zerotal/flow-ui`, because they are the kit's
6
+ * tokens: flow-ui components are written against `bg-primary`,
7
+ * `text-muted-foreground`, `border-input` and friends, so whoever defines those
8
+ * variables themes the components. The admin is one consumer of that theme and
9
+ * the monitor is another; keeping the palette in the kit is what stops the two
10
+ * from drifting apart.
11
+ *
12
+ * This module is the admin's thin wrapper over it: the same head payload, under
13
+ * the names the panel's own config uses.
14
+ */
15
+ import {
16
+ flowUiHead,
17
+ flowTokensCss,
18
+ flowTailwindConfig,
19
+ THEME_STORAGE_KEY as FLOW_THEME_STORAGE_KEY,
20
+ THEME_TOGGLE_SCRIPT as FLOW_THEME_TOGGLE_SCRIPT,
21
+ } from "@zerotal/flow-ui";
22
+
23
+ /** localStorage key the toggle and the no-flash script share. */
24
+ export const THEME_STORAGE_KEY = FLOW_THEME_STORAGE_KEY;
25
+
26
+ /** Client helpers (theme toggle + copy-to-clipboard), eval-free. */
27
+ export const THEME_TOGGLE_SCRIPT = FLOW_THEME_TOGGLE_SCRIPT;
28
+
29
+ /**
30
+ * Styling source for the admin shell. By default the Tailwind **Play CDN** themes
31
+ * everything with zero build step. To ship a real build, point `stylesheet` at
32
+ * your compiled CSS (built from your own `tailwind.config` — reuse
33
+ * {@link adminTailwindConfig} + {@link adminTokensCss}); the CDN is then dropped
34
+ * automatically (set `cdn: true` to keep both during migration).
35
+ */
36
+ export interface AdminThemeConfig {
37
+ /** A prebuilt stylesheet URL/path to link instead of (or alongside) the CDN. */
38
+ stylesheet?: string;
39
+ /** Keep loading the Tailwind Play CDN. Defaults to `true` unless `stylesheet` is set. */
40
+ cdn?: boolean;
41
+ /** Extra design-token CSS appended after the defaults (override `:root` / `.dark`). */
42
+ tokensCss?: string;
43
+ /** Skip the bundled Google Fonts links (e.g. you self-host Inter). */
44
+ noFonts?: boolean;
45
+ }
46
+
47
+ /** The design-token CSS (`:root` + `.dark` custom properties + base styles). */
48
+ export function adminTokensCss(): string {
49
+ return flowTokensCss();
50
+ }
51
+
52
+ /**
53
+ * The Tailwind config (token → CSS-var mappings, fonts, radius, animations) as a
54
+ * JS string. Reuse it in your own `tailwind.config.js` when building real CSS so a
55
+ * compiled build matches the CDN look exactly.
56
+ */
57
+ export function adminTailwindConfig(): string {
58
+ return flowTailwindConfig();
59
+ }
60
+
61
+ /**
62
+ * Full `<head>` markup for the admin shell. Inject as `Layout.head`.
63
+ *
64
+ * With no `theme` (or `theme.cdn !== false`) it loads the Tailwind Play CDN. Set
65
+ * `theme.stylesheet` to link a prebuilt CSS file and drop the CDN — only this
66
+ * file's wiring changes; pages stay identical.
67
+ */
68
+ export function adminHead(title = "Admin", theme: AdminThemeConfig = {}): string {
69
+ return flowUiHead(title, theme);
70
+ }