@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,928 @@
1
+ /**
2
+ * Form fields — the editable counterpart to {@link Entry} infolist entries,
3
+ * declared once and rendered by the form page. A
4
+ * field describes an input plus its validation; the Create/Edit page renders it
5
+ * and a generated {@link Form} (see ./ResourceForm) backs the reactive binding.
6
+ *
7
+ * textInput("name").required().maxLength(120)
8
+ * textInput("email").email().required().placeholder("you@example.com")
9
+ * textInput("password").password().required().minLength(8).visibleOn("create")
10
+ * textarea("bio").rows(4)
11
+ * select("role").options({ admin: "Admin", member: "Member" }).required()
12
+ * checkbox("is_active").label("Active").default(true)
13
+ */
14
+ import type { HtmlNode } from "@zerotal/flow";
15
+ import type { RuleBuilder } from "@zerotal/validator";
16
+ import type { FieldRule } from "@zerotal/validator";
17
+
18
+ export type FieldType =
19
+ | "text"
20
+ | "email"
21
+ | "password"
22
+ | "number"
23
+ | "url"
24
+ | "tel"
25
+ | "textarea"
26
+ | "select"
27
+ | "checkbox"
28
+ | "toggle"
29
+ | "radio"
30
+ | "checkboxList"
31
+ | "date"
32
+ | "datetime"
33
+ | "time"
34
+ | "color"
35
+ | "hidden"
36
+ | "tags"
37
+ | "keyValue"
38
+ | "file"
39
+ | "media"
40
+ | "slider"
41
+ | "toggleButtons"
42
+ | "code"
43
+ | "markdown"
44
+ | "richText"
45
+ | "repeater"
46
+ | "builder"
47
+ | "custom";
48
+
49
+ export type FieldMode = "create" | "edit";
50
+
51
+ /** Predicate over the current form data — for reactive visibility / disabling. */
52
+ export type FieldPredicate = (data: Record<string, unknown>) => boolean;
53
+
54
+ export interface SelectOption {
55
+ value: string;
56
+ label: string;
57
+ }
58
+
59
+ /**
60
+ * A named block type for a {@link builder} field.
61
+ * Each block has its own sub-schema; a builder row records which block it is.
62
+ *
63
+ * builderBlock("heading").icon("type").schema([
64
+ * textInput("content").required(),
65
+ * select("level").options({ h2: "H2", h3: "H3" }),
66
+ * ])
67
+ */
68
+ export class BuilderBlock {
69
+ /** @internal */ name: string;
70
+ /** @internal */ _label?: string;
71
+ /** @internal */ _icon?: string;
72
+ /** @internal */ _fields: Field[] = [];
73
+
74
+ constructor(name: string) {
75
+ this.name = name;
76
+ }
77
+
78
+ label(label: string): this {
79
+ this._label = label;
80
+ return this;
81
+ }
82
+
83
+ icon(name: string): this {
84
+ this._icon = name;
85
+ return this;
86
+ }
87
+
88
+ schema(fields: Field[]): this {
89
+ this._fields = fields;
90
+ return this;
91
+ }
92
+
93
+ getLabel(): string {
94
+ return this._label ?? titleCase(this.name);
95
+ }
96
+ }
97
+
98
+ /** Declare a block type a builder field can offer. */
99
+ export function builderBlock(name: string): BuilderBlock {
100
+ return new BuilderBlock(name);
101
+ }
102
+
103
+ /** A permissive view of a validator rule (methods vary by base type). */
104
+ interface AnyRule {
105
+ required(message?: string): AnyRule;
106
+ optional(): AnyRule;
107
+ email?(message?: string): AnyRule;
108
+ url?(message?: string): AnyRule;
109
+ min?(n: number, message?: string): AnyRule;
110
+ max?(n: number, message?: string): AnyRule;
111
+ confirmed?(message?: string): AnyRule;
112
+ in?(values: string[], message?: string): AnyRule;
113
+ }
114
+
115
+ export class Field {
116
+ /** @internal */ _key: string;
117
+ /** @internal */ _label?: string;
118
+ /** @internal */ _type: FieldType = "text";
119
+ /** @internal */ _required = false;
120
+ /** @internal */ _placeholder?: string;
121
+ /** @internal */ _helper?: string;
122
+ /** @internal */ _default?: unknown;
123
+ /** @internal */ _disabled = false;
124
+ /** @internal */ _columnSpan = 1;
125
+ /** @internal */ _minLength?: number;
126
+ /** @internal */ _maxLength?: number;
127
+ /** @internal */ _min?: number;
128
+ /** @internal */ _max?: number;
129
+ /** @internal */ _confirmed = false;
130
+ /** @internal */ _options?: SelectOption[];
131
+ /** @internal */ _optionsLoader?: () => Promise<SelectOption[]> | SelectOption[];
132
+ /** @internal */ _rows = 4;
133
+ /** @internal */ _autocomplete?: string;
134
+ /** @internal */ _visibleOn?: FieldMode[];
135
+ /** @internal */ _rule?: (rule: AnyRule) => AnyRule;
136
+ /** @internal */ _mutate?: (value: unknown) => unknown | Promise<unknown>;
137
+ /** @internal */ _multiple = false;
138
+ /** @internal */ _searchable = false;
139
+ /** @internal */ _step?: number;
140
+ /** @internal */ _visibleFn?: FieldPredicate;
141
+ /** @internal */ _disabledFn?: FieldPredicate;
142
+ /** @internal Allow values not in `options` (free entry on a searchable select). */
143
+ _createOption = false;
144
+ /** @internal Re-render on every change so dependent fields update. */
145
+ _live = false;
146
+ /** @internal Reactive hook — return a patch merged into the form on change. */
147
+ _afterUpdate?: (value: unknown, data: Record<string, unknown>) => Record<string, unknown> | void;
148
+ /** @internal File upload: target storage directory/disk path. */
149
+ _uploadDir = "uploads";
150
+ /** @internal File upload: `accept` attribute. */
151
+ _accept?: string;
152
+ /** @internal Repeater sub-schema — the fields repeated for each row. */
153
+ _subfields: Field[] = [];
154
+ /** @internal Builder block definitions (each a named, labelled sub-schema). */
155
+ _blocks: BuilderBlock[] = [];
156
+ /** @internal A custom control, replacing every built-in field type. */
157
+ _render?: (value: unknown, data: Record<string, unknown>) => HtmlNode | string;
158
+ /** @internal Label for the repeater/builder "add" button. */
159
+ _addLabel?: string;
160
+ /** @internal Minimum number of repeater/builder rows. */
161
+ _minItems?: number;
162
+ /** @internal Maximum number of repeater/builder rows. */
163
+ _maxItems?: number;
164
+ /** @internal Whether repeater/builder rows can be reordered. */
165
+ _reorderable = true;
166
+ /** @internal Title each repeater/builder row (collapsed header). */
167
+ _itemLabel?: (data: Record<string, unknown>, index: number) => string;
168
+
169
+ constructor(key: string, type: FieldType = "text") {
170
+ this._key = key;
171
+ this._type = type;
172
+ }
173
+
174
+ static make(key: string): Field {
175
+ return new Field(key);
176
+ }
177
+
178
+ // ── Identity ─────────────────────────────────────────────────────────────
179
+
180
+ label(label: string): this {
181
+ this._label = label;
182
+ return this;
183
+ }
184
+
185
+ placeholder(text: string): this {
186
+ this._placeholder = text;
187
+ return this;
188
+ }
189
+
190
+ /** Hint text shown beneath the input. */
191
+ helperText(text: string): this {
192
+ this._helper = text;
193
+ return this;
194
+ }
195
+
196
+ default(value: unknown): this {
197
+ this._default = value;
198
+ return this;
199
+ }
200
+
201
+ disabled(value = true): this {
202
+ this._disabled = value;
203
+ return this;
204
+ }
205
+
206
+ columnSpan(span: number): this {
207
+ this._columnSpan = Math.max(1, span);
208
+ return this;
209
+ }
210
+
211
+ autocomplete(value: string): this {
212
+ this._autocomplete = value;
213
+ return this;
214
+ }
215
+
216
+ // ── Type modifiers ───────────────────────────────────────────────────────
217
+
218
+ email(): this {
219
+ this._type = "email";
220
+ return this;
221
+ }
222
+
223
+ password(): this {
224
+ this._type = "password";
225
+ return this;
226
+ }
227
+
228
+ numeric(): this {
229
+ this._type = "number";
230
+ return this;
231
+ }
232
+
233
+ url(): this {
234
+ this._type = "url";
235
+ return this;
236
+ }
237
+
238
+ tel(): this {
239
+ this._type = "tel";
240
+ return this;
241
+ }
242
+
243
+ /** Render as an on/off toggle switch (boolean). */
244
+ toggle(): this {
245
+ this._type = "toggle";
246
+ return this;
247
+ }
248
+
249
+ /** Render as a radio group (single choice from `options`). */
250
+ radio(): this {
251
+ this._type = "radio";
252
+ return this;
253
+ }
254
+
255
+ /** Render as a list of checkboxes (multi-select → array of values). */
256
+ checkboxList(): this {
257
+ this._type = "checkboxList";
258
+ this._multiple = true;
259
+ return this;
260
+ }
261
+
262
+ /** Native date picker (`<input type="date">`). */
263
+ date(): this {
264
+ this._type = "date";
265
+ return this;
266
+ }
267
+
268
+ /** Native date-time picker (`<input type="datetime-local">`). */
269
+ dateTime(): this {
270
+ this._type = "datetime";
271
+ return this;
272
+ }
273
+
274
+ /** Native time picker (`<input type="time">`). */
275
+ time(): this {
276
+ this._type = "time";
277
+ return this;
278
+ }
279
+
280
+ /** Native color picker (`<input type="color">`). */
281
+ color(): this {
282
+ this._type = "color";
283
+ return this;
284
+ }
285
+
286
+ /** Hidden field — kept in form state, not shown. */
287
+ hidden(): this {
288
+ this._type = "hidden";
289
+ return this;
290
+ }
291
+
292
+ /** Token/tags input → array of strings. */
293
+ tags(): this {
294
+ this._type = "tags";
295
+ return this;
296
+ }
297
+
298
+ /** Key/value editor → an object. */
299
+ keyValue(): this {
300
+ this._type = "keyValue";
301
+ return this;
302
+ }
303
+
304
+ /** File upload — stores the file on save and persists the path. */
305
+ file(): this {
306
+ this._type = "file";
307
+ return this;
308
+ }
309
+
310
+ /** Storage directory/disk for a file upload (passed to `TemporaryUploadedFile.store`). */
311
+ disk(dir: string): this {
312
+ this._uploadDir = dir;
313
+ return this;
314
+ }
315
+
316
+ /** Restrict accepted file types (the input's `accept` attribute). */
317
+ accept(mime: string): this {
318
+ this._accept = mime;
319
+ return this;
320
+ }
321
+
322
+ /** Shorthand for an image upload (`accept="image/*"`). */
323
+ image(): this {
324
+ this._type = "file";
325
+ this._accept = "image/*";
326
+ return this;
327
+ }
328
+
329
+ /** Allow selecting multiple values (select → array; rendered as a checkbox list). */
330
+ multiple(value = true): this {
331
+ this._multiple = value;
332
+ return this;
333
+ }
334
+
335
+ /** Make a select searchable (renders a filterable `<datalist>` combobox). */
336
+ searchable(value = true): this {
337
+ this._searchable = value;
338
+ return this;
339
+ }
340
+
341
+ /** Allow choosing a value not in `options` (free entry; skips the one-of rule). */
342
+ createOption(value = true): this {
343
+ this._createOption = value;
344
+ this._searchable = true;
345
+ return this;
346
+ }
347
+
348
+ /** Re-evaluate dependent fields on every change to this field. */
349
+ live(value = true): this {
350
+ this._live = value;
351
+ return this;
352
+ }
353
+
354
+ /**
355
+ * Reactive hook, run on the server when this
356
+ * field changes; return an object to merge into the form (e.g. derive a slug):
357
+ *
358
+ * textInput("title").live()
359
+ * .afterStateUpdated((v) => ({ slug: String(v).toLowerCase().replace(/\\s+/g, "-") }))
360
+ */
361
+ afterStateUpdated(
362
+ fn: (value: unknown, data: Record<string, unknown>) => Record<string, unknown> | void,
363
+ ): this {
364
+ this._afterUpdate = fn;
365
+ this._live = true;
366
+ return this;
367
+ }
368
+
369
+ /** Numeric step for number/range inputs. */
370
+ step(n: number): this {
371
+ this._step = n;
372
+ return this;
373
+ }
374
+
375
+ // ── Repeater / Builder (nested object-arrays) ──────────────────────────────
376
+
377
+ /** The sub-schema repeated for each row of a {@link repeater}. */
378
+ schema(fields: Field[]): this {
379
+ this._subfields = fields;
380
+ return this;
381
+ }
382
+
383
+ /** The block types available in a {@link builder}. */
384
+ blocks(blocks: BuilderBlock[]): this {
385
+ this._blocks = blocks;
386
+ return this;
387
+ }
388
+
389
+ /** Custom label for the repeater/builder "add" button. */
390
+ addActionLabel(label: string): this {
391
+ this._addLabel = label;
392
+ return this;
393
+ }
394
+
395
+ /** Minimum number of repeater/builder rows. */
396
+ minItems(n: number): this {
397
+ this._minItems = Math.max(0, n);
398
+ return this;
399
+ }
400
+
401
+ /** Maximum number of repeater/builder rows. */
402
+ maxItems(n: number): this {
403
+ this._maxItems = Math.max(1, n);
404
+ return this;
405
+ }
406
+
407
+ /** Allow (or forbid) reordering repeater/builder rows. */
408
+ reorderable(value = true): this {
409
+ this._reorderable = value;
410
+ return this;
411
+ }
412
+
413
+ /** Title each repeater/builder row from its data (shown in the row header). */
414
+ itemLabel(fn: (data: Record<string, unknown>, index: number) => string): this {
415
+ this._itemLabel = fn;
416
+ return this;
417
+ }
418
+
419
+ /** Rows for a textarea. */
420
+ rows(n: number): this {
421
+ this._rows = Math.max(1, n);
422
+ return this;
423
+ }
424
+
425
+ /**
426
+ * Render this field's control yourself. See {@link customField}.
427
+ *
428
+ * The renderer receives the current value and the whole form's data, so a
429
+ * control can react to its siblings — a district picker that depends on the
430
+ * country chosen above it.
431
+ */
432
+ render(fn: (value: unknown, data: Record<string, unknown>) => HtmlNode | string): this {
433
+ this._render = fn;
434
+ return this;
435
+ }
436
+
437
+ /** Options for a select — `{ value: label }` map or an array of `{value,label}`. */
438
+ options(options: Record<string, string> | SelectOption[]): this {
439
+ this._options = Array.isArray(options)
440
+ ? options
441
+ : Object.entries(options).map(([value, label]) => ({ value, label }));
442
+ return this;
443
+ }
444
+
445
+ /**
446
+ * Load select options dynamically (e.g. a BelongsTo relationship). The loader
447
+ * runs each render; return `{ value, label }[]`.
448
+ *
449
+ * select("userId").label("Author")
450
+ * .optionsUsing(async () => (await User.all()).map((u) => ({ value: String(u.id), label: u.name })))
451
+ */
452
+ optionsUsing(loader: () => Promise<SelectOption[]> | SelectOption[]): this {
453
+ this._type = "select";
454
+ this._optionsLoader = loader;
455
+ return this;
456
+ }
457
+
458
+ // ── Validation ───────────────────────────────────────────────────────────
459
+
460
+ required(value = true): this {
461
+ this._required = value;
462
+ return this;
463
+ }
464
+
465
+ /** Min length (string) / min value (number). */
466
+ minLength(n: number): this {
467
+ this._minLength = n;
468
+ return this;
469
+ }
470
+
471
+ maxLength(n: number): this {
472
+ this._maxLength = n;
473
+ return this;
474
+ }
475
+
476
+ min(n: number): this {
477
+ this._min = n;
478
+ return this;
479
+ }
480
+
481
+ max(n: number): this {
482
+ this._max = n;
483
+ return this;
484
+ }
485
+
486
+ /** Require a matching `{key}_confirmation` field (e.g. password). */
487
+ confirmed(value = true): this {
488
+ this._confirmed = value;
489
+ return this;
490
+ }
491
+
492
+ /** Append a custom validator rule. */
493
+ rule(fn: (rule: AnyRule) => AnyRule): this {
494
+ this._rule = fn;
495
+ return this;
496
+ }
497
+
498
+ /**
499
+ * Transform the value just before it is saved — the counterpart to
500
+ * {@link Field.hydrate}. May be async, e.g. hashing a password:
501
+ * `.mutate((v) => Hash.make(v))`.
502
+ */
503
+ mutate(fn: (value: unknown) => unknown | Promise<unknown>): this {
504
+ this._mutate = fn;
505
+ return this;
506
+ }
507
+
508
+ /**
509
+ * Coerce an empty optional value so nullable columns don't choke on `""`.
510
+ *
511
+ * A blank `datePicker`/`select`/`numeric` field submits the empty string, which
512
+ * a date/number/foreign-key cast can't parse (`Cannot parse date: ""`). For the
513
+ * types where `""` is never a valid stored value we map blank → `null`, and we
514
+ * parse numeric strings to real numbers. Text-like fields are left untouched
515
+ * (an empty string is a legitimate value for a `NOT NULL DEFAULT ''` column).
516
+ * Required fields are never coerced — validation has already rejected a blank.
517
+ */
518
+ private _coerceEmpty(value: unknown): unknown {
519
+ const blank =
520
+ value === "" || (typeof value === "string" && value.trim() === "") || value === undefined;
521
+ const nullableTypes: FieldType[] = [
522
+ "date",
523
+ "datetime",
524
+ "time",
525
+ "number",
526
+ "slider",
527
+ "select",
528
+ "radio",
529
+ "color",
530
+ ];
531
+ if (blank && !this._required && nullableTypes.includes(this._type) && !this.isArrayValued()) {
532
+ return null;
533
+ }
534
+ if (
535
+ (this._type === "number" || this._type === "slider") &&
536
+ typeof value === "string" &&
537
+ value.trim() !== ""
538
+ ) {
539
+ const n = Number(value);
540
+ return Number.isFinite(n) ? n : value;
541
+ }
542
+ return value;
543
+ }
544
+
545
+ /**
546
+ * Apply this field's save-time transform: serialize key/value text → object,
547
+ * persist any pending file upload(s) → stored path, then run a custom mutator.
548
+ */
549
+ async dehydrate(value: unknown): Promise<unknown> {
550
+ let v = this._coerceEmpty(value);
551
+ if (this._type === "keyValue" && typeof v === "string") v = parseKeyValueLines(v);
552
+ if (this._type === "file") v = await storeUploads(v, this._uploadDir);
553
+ // Strip the internal `__id` (repeater) / `__id`+`__type` (builder → `{type,data}`).
554
+ if (this._type === "repeater" && Array.isArray(v)) {
555
+ v = v.map((row) => {
556
+ const { __id: _id, ...rest } = row as Record<string, unknown>;
557
+ return rest;
558
+ });
559
+ }
560
+ if (this._type === "builder" && Array.isArray(v)) {
561
+ v = v.map((row) => {
562
+ const { __id: _id, __type, ...rest } = row as Record<string, unknown>;
563
+ return { type: __type, data: rest };
564
+ });
565
+ }
566
+ return this._mutate ? await this._mutate(v) : v;
567
+ }
568
+
569
+ // ── Visibility per mode ──────────────────────────────────────────────────
570
+
571
+ /** Only show this field when creating / editing. */
572
+ visibleOn(...modes: FieldMode[]): this {
573
+ this._visibleOn = modes;
574
+ return this;
575
+ }
576
+
577
+ /** Hide this field when creating / editing. */
578
+ hiddenOn(...modes: FieldMode[]): this {
579
+ const all: FieldMode[] = ["create", "edit"];
580
+ this._visibleOn = all.filter((m) => !modes.includes(m));
581
+ return this;
582
+ }
583
+
584
+ /** Reactively show this field only when `fn(formData)` is true. */
585
+ visible(fn: FieldPredicate): this {
586
+ this._visibleFn = fn;
587
+ return this;
588
+ }
589
+
590
+ /** Reactively disable this field when `fn(formData)` is true. */
591
+ disabledWhen(fn: FieldPredicate): this {
592
+ this._disabledFn = fn;
593
+ return this;
594
+ }
595
+
596
+ // ── Resolution ───────────────────────────────────────────────────────────
597
+
598
+ getLabel(): string {
599
+ return this._label ?? titleCase(this._key);
600
+ }
601
+
602
+ visibleIn(mode: FieldMode): boolean {
603
+ return this._visibleOn ? this._visibleOn.includes(mode) : true;
604
+ }
605
+
606
+ /** Reactive visibility for the current form data (combines with `visibleIn`). */
607
+ visibleForData(data: Record<string, unknown>): boolean {
608
+ return this._visibleFn ? this._visibleFn(data) : true;
609
+ }
610
+
611
+ /** Whether the field is disabled for the current form data. */
612
+ isDisabledFor(data: Record<string, unknown>): boolean {
613
+ if (this._disabled) return true;
614
+ return this._disabledFn ? this._disabledFn(data) : false;
615
+ }
616
+
617
+ /** True for array-valued fields (checkbox list / tags / multiple select / multiple file). */
618
+ isArrayValued(): boolean {
619
+ return (
620
+ this._type === "checkboxList" ||
621
+ this._type === "tags" ||
622
+ (this._type === "select" && this._multiple) ||
623
+ (this._type === "file" && this._multiple) ||
624
+ (this._type === "toggleButtons" && this._multiple)
625
+ );
626
+ }
627
+
628
+ /** Initial value for the generated form. */
629
+ defaultValue(): unknown {
630
+ if (this._default !== undefined) return this._default;
631
+ if (this._type === "repeater" || this._type === "builder") return [];
632
+ if (this._type === "file") return this._multiple ? [] : null;
633
+ if (this._type === "keyValue") return "";
634
+ if (this.isArrayValued()) return [];
635
+ if (this._type === "checkbox" || this._type === "toggle") return false;
636
+ if (this._type === "slider") return this._min ?? 0;
637
+ return "";
638
+ }
639
+
640
+ /** Transform a stored record value into form state when filling the Edit form. */
641
+ hydrate(value: unknown): unknown {
642
+ if (this._type === "keyValue" && value && typeof value === "object") {
643
+ return keyValueToLines(value as Record<string, unknown>);
644
+ }
645
+ if (this._type === "tags" && typeof value === "string") {
646
+ return value
647
+ .split(",")
648
+ .map((s) => s.trim())
649
+ .filter(Boolean);
650
+ }
651
+ // Repeater rows gain a stable `__id` for keyed reorder/remove + draft binding.
652
+ if (this._type === "repeater" && Array.isArray(value)) {
653
+ return value.map((row, i) => ({ __id: i + 1, ...(row as Record<string, unknown>) }));
654
+ }
655
+ // Builder rows are stored as `{ type, data }`; flatten into `{ __id, __type, ...data }`.
656
+ if (this._type === "builder" && Array.isArray(value)) {
657
+ return value.map((row, i) => {
658
+ const r = (row ?? {}) as { type?: string; data?: Record<string, unknown> };
659
+ return { __id: i + 1, __type: r.type ?? "", ...(r.data ?? {}) };
660
+ });
661
+ }
662
+ return value;
663
+ }
664
+
665
+ /** Build this field's validation rule on the shared {@link RuleBuilder}. */
666
+ buildRule(v: RuleBuilder): FieldRule {
667
+ const vAny = v as unknown as {
668
+ number(): AnyRule;
669
+ boolean(): AnyRule;
670
+ string(): AnyRule;
671
+ // The validator's array() needs an element rule (array(itemRule)).
672
+ array?(item: AnyRule): AnyRule;
673
+ };
674
+ /** Array rule over string elements (tags, checkbox-list, multi-select, …). */
675
+ const arrayRule = (): AnyRule => (vAny.array ? vAny.array(vAny.string()) : vAny.string());
676
+
677
+ // File uploads hold temp-file objects → text validators can't model them.
678
+ if (this._type === "file") {
679
+ const rf = vAny.string().optional();
680
+ return (this._rule ? this._rule(rf) : rf) as unknown as FieldRule;
681
+ }
682
+
683
+ // Repeater/builder hold arrays of objects — sub-fields validate per-row in
684
+ // the page; the top-level rule stays lenient (optional array).
685
+ if (this._type === "repeater" || this._type === "builder") {
686
+ const ra = arrayRule().optional();
687
+ return (this._rule ? this._rule(ra) : ra) as unknown as FieldRule;
688
+ }
689
+
690
+ let r: AnyRule;
691
+ if (this._type === "number" || this._type === "slider") r = vAny.number();
692
+ else if (this._type === "checkbox" || this._type === "toggle") r = vAny.boolean();
693
+ else if (this.isArrayValued()) r = arrayRule();
694
+ else r = vAny.string();
695
+
696
+ // Array fields without native `array()` support stay optional to avoid a
697
+ // spurious "must be a string" failure against an array value.
698
+ const lenientArray = this.isArrayValued() && !vAny.array;
699
+ r = this._required && !lenientArray ? r.required() : r.optional();
700
+
701
+ if (this._type === "email" && r.email) r = r.email();
702
+ if (this._type === "url" && r.url) r = r.url();
703
+ const numeric = this._type === "number" || this._type === "slider";
704
+ if (!this.isArrayValued() && !numeric && this._type !== "keyValue") {
705
+ if (this._minLength != null && r.min) r = r.min(this._minLength);
706
+ if (this._maxLength != null && r.max) r = r.max(this._maxLength);
707
+ }
708
+ if (numeric) {
709
+ if (this._min != null && r.min) r = r.min(this._min);
710
+ if (this._max != null && r.max) r = r.max(this._max);
711
+ }
712
+ if (this._confirmed && r.confirmed) r = r.confirmed();
713
+ // `in` (one-of) applies to restricted single-choice fields (not free-entry).
714
+ if (
715
+ !this.isArrayValued() &&
716
+ !this._createOption &&
717
+ this._options &&
718
+ this._options.length > 0 &&
719
+ r.in
720
+ ) {
721
+ r = r.in(this._options.map((o) => String(o.value)));
722
+ }
723
+ if (this._rule) r = this._rule(r);
724
+ return r as unknown as FieldRule;
725
+ }
726
+ }
727
+
728
+ /** A single-line text input. */
729
+ export function textInput(key: string): Field {
730
+ return new Field(key, "text");
731
+ }
732
+
733
+ /** A multi-line text input. */
734
+ export function textarea(key: string): Field {
735
+ return new Field(key, "textarea");
736
+ }
737
+
738
+ /** A single-choice dropdown. */
739
+ export function select(key: string): Field {
740
+ return new Field(key, "select");
741
+ }
742
+
743
+ /** A single checkbox → boolean. */
744
+ export function checkbox(key: string): Field {
745
+ return new Field(key, "checkbox");
746
+ }
747
+
748
+ /** On/off switch. */
749
+ export function toggle(key: string): Field {
750
+ return new Field(key, "toggle");
751
+ }
752
+
753
+ /** Single-choice radio group. */
754
+ export function radio(key: string): Field {
755
+ return new Field(key, "radio");
756
+ }
757
+
758
+ /** Multi-choice checkbox list → array value. */
759
+ export function checkboxList(key: string): Field {
760
+ return new Field(key, "checkboxList").checkboxList();
761
+ }
762
+
763
+ /** Native date picker. */
764
+ export function datePicker(key: string): Field {
765
+ return new Field(key, "date");
766
+ }
767
+
768
+ /** Native date-time picker. */
769
+ export function dateTimePicker(key: string): Field {
770
+ return new Field(key, "datetime");
771
+ }
772
+
773
+ /** Native time picker. */
774
+ export function timePicker(key: string): Field {
775
+ return new Field(key, "time");
776
+ }
777
+
778
+ /** Native color picker. */
779
+ export function colorPicker(key: string): Field {
780
+ return new Field(key, "color");
781
+ }
782
+
783
+ /** Hidden field retained in form state. */
784
+ export function hidden(key: string): Field {
785
+ return new Field(key, "hidden");
786
+ }
787
+
788
+ /** Tags / tokens input → array of strings. */
789
+ export function tagsInput(key: string): Field {
790
+ return new Field(key, "tags");
791
+ }
792
+
793
+ /** Key/value editor → object. */
794
+ export function keyValue(key: string): Field {
795
+ return new Field(key, "keyValue");
796
+ }
797
+
798
+ /** File upload. Stores the file on save and persists the path. */
799
+ export function fileUpload(key: string): Field {
800
+ return new Field(key, "file");
801
+ }
802
+
803
+ /**
804
+ * Pick a file from the media library, or upload a new one into it.
805
+ *
806
+ * The difference from {@link fileUpload} is reuse: an upload field puts a file
807
+ * somewhere and forgets it, while this one catalogues what it stores, so the
808
+ * same image can be chosen again from anywhere in the panel. Needs a media
809
+ * provider on the panel; without one it behaves as a plain upload.
810
+ */
811
+ export function mediaPicker(key: string): Field {
812
+ return new Field(key, "media");
813
+ }
814
+
815
+ /** Range slider → number. Pair with `.min()/.max()/.step()`. */
816
+ export function slider(key: string): Field {
817
+ return new Field(key, "slider");
818
+ }
819
+
820
+ /** Segmented toggle buttons. `.options()` + optional `.multiple()`. */
821
+ export function toggleButtons(key: string): Field {
822
+ return new Field(key, "toggleButtons");
823
+ }
824
+
825
+ /** Monospace code editor with tab support. */
826
+ export function codeEditor(key: string): Field {
827
+ return new Field(key, "code");
828
+ }
829
+
830
+ /** Markdown editor — a textarea with a formatting toolbar. */
831
+ export function markdownEditor(key: string): Field {
832
+ return new Field(key, "markdown");
833
+ }
834
+
835
+ /** Rich text (WYSIWYG) editor → HTML. */
836
+ export function richEditor(key: string): Field {
837
+ return new Field(key, "richText");
838
+ }
839
+
840
+ /**
841
+ * Repeater → array of objects sharing one sub-schema.
842
+ * Add/remove/reorder rows; each row's fields bind through a flat draft:
843
+ *
844
+ * repeater("contacts").schema([
845
+ * textInput("name").required(),
846
+ * textInput("email").email(),
847
+ * ]).minItems(1).addActionLabel("Add contact")
848
+ */
849
+ export function repeater(key: string): Field {
850
+ return new Field(key, "repeater");
851
+ }
852
+
853
+ /**
854
+ * Builder → array of typed blocks, each with its own schema. Declare the
855
+ * block types with {@link builderBlock}. Stored as `[{ type, data }]`:
856
+ *
857
+ * builder("content").blocks([
858
+ * builderBlock("paragraph").schema([textarea("text")]),
859
+ * builderBlock("image").icon("photo").schema([fileUpload("src").image()]),
860
+ * ])
861
+ */
862
+ export function builder(key: string): Field {
863
+ return new Field(key, "builder");
864
+ }
865
+
866
+ /**
867
+ * A control you render yourself, when nothing in the catalogue fits — a map
868
+ * picker, a colour-ramp editor, a signature pad.
869
+ *
870
+ * customField("coordinates").render((value) => <MapPicker value={value} />)
871
+ *
872
+ * The renderer owns the control; the field still owns the label, helper text,
873
+ * validation rules and the `form.<key>` binding, so a custom control saves and
874
+ * validates like any other. Bind your markup to `form.<key>` for the value to
875
+ * round-trip.
876
+ */
877
+ export function customField(key: string): Field {
878
+ return new Field(key, "custom");
879
+ }
880
+
881
+ // ── Serialization helpers ───────────────────────────────────────────────────────
882
+
883
+ /** Parse a `key: value` (one per line) textarea into an object. */
884
+ function parseKeyValueLines(text: string): Record<string, string> {
885
+ const out: Record<string, string> = {};
886
+ for (const line of text.split("\n")) {
887
+ const trimmed = line.trim();
888
+ if (!trimmed) continue;
889
+ const idx = trimmed.indexOf(":");
890
+ if (idx === -1) {
891
+ out[trimmed] = "";
892
+ continue;
893
+ }
894
+ out[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
895
+ }
896
+ return out;
897
+ }
898
+
899
+ /** Render an object back into `key: value` lines for editing. */
900
+ function keyValueToLines(obj: Record<string, unknown>): string {
901
+ return Object.entries(obj)
902
+ .map(([k, v]) => `${k}: ${v ?? ""}`)
903
+ .join("\n");
904
+ }
905
+
906
+ /** A pending upload exposes an async `store(dir)` that returns the stored path. */
907
+ interface StorableUpload {
908
+ store(dir: string): Promise<string>;
909
+ }
910
+ function isStorable(v: unknown): v is StorableUpload {
911
+ return !!v && typeof (v as { store?: unknown }).store === "function";
912
+ }
913
+
914
+ /** Persist any pending upload(s) and replace them with their stored path(s). */
915
+ async function storeUploads(value: unknown, dir: string): Promise<unknown> {
916
+ if (Array.isArray(value)) {
917
+ return Promise.all(value.map((v) => (isStorable(v) ? v.store(dir) : v)));
918
+ }
919
+ return isStorable(value) ? value.store(dir) : value;
920
+ }
921
+
922
+ function titleCase(key: string): string {
923
+ return key
924
+ .replace(/[_-]+/g, " ")
925
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
926
+ .replace(/\b\w/g, (c) => c.toUpperCase())
927
+ .trim();
928
+ }