@riducms/admin 0.1.5 → 0.2.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 (66) hide show
  1. package/package.json +11 -9
  2. package/src/app.css +63 -4
  3. package/src/app.svelte +12 -14
  4. package/src/components/ui/date-value-control/date-value-control-loader.svelte +15 -4
  5. package/src/components/ui/date-value-control/date-value-control.svelte +17 -17
  6. package/src/components/ui/select/select-trigger.svelte +1 -1
  7. package/src/core/forms/embedded-fields.ts +132 -0
  8. package/src/core/forms/embedded-schema-draft.svelte.ts +335 -0
  9. package/src/core/forms/field-editor-authoring.ts +47 -0
  10. package/src/core/forms/field-editor-binding.ts +228 -0
  11. package/src/core/forms/field-issue-focus.ts +42 -0
  12. package/src/core/forms/field-occurrence.ts +122 -0
  13. package/src/core/forms/form-controller.svelte.ts +475 -29
  14. package/src/core/forms/form-issue-correlation.ts +159 -0
  15. package/src/core/forms/form-schema.ts +211 -17
  16. package/src/core/forms/form-validation.ts +183 -17
  17. package/src/core/forms/live-validation.svelte.ts +317 -0
  18. package/src/core/forms/plugin-field-authoring.ts +160 -0
  19. package/src/core/forms/plugin-field-binding.ts +258 -0
  20. package/src/core/forms/plugin-field-write.ts +57 -0
  21. package/src/core/i18n/admin-i18n.svelte.ts +8 -1
  22. package/src/core/i18n/localized-schema.ts +33 -10
  23. package/src/core/plugins/field-registry.ts +29 -49
  24. package/src/core/plugins/row-label-registry.ts +25 -5
  25. package/src/core/runtime/admin-runtime.svelte.ts +25 -13
  26. package/src/features/auth/account-route.svelte +10 -5
  27. package/src/features/auth/account-security-route.svelte +1 -1
  28. package/src/features/collections/collection-list-bulk-editor.svelte +6 -6
  29. package/src/features/collections/collection-list-controller.svelte.ts +5 -5
  30. package/src/features/collections/collection-list-filter-popover.svelte +6 -6
  31. package/src/features/collections/collection-list-route.svelte +13 -14
  32. package/src/features/collections/list-workspace.ts +22 -8
  33. package/src/features/documents/document-controller.svelte.ts +8 -2
  34. package/src/features/documents/document-route.svelte +36 -43
  35. package/src/features/reference-browser/reference-browser-workflow.svelte.ts +30 -15
  36. package/src/features/reference-browser/reference-browser.svelte +215 -189
  37. package/src/features/uploads/upload-metadata-fields.svelte +6 -6
  38. package/src/features/versions/version-history-route.svelte +1 -1
  39. package/src/fields/embedded-schema-draft-editor.svelte +74 -0
  40. package/src/fields/embedded-schema-fields.svelte +44 -0
  41. package/src/fields/field-clipboard.ts +76 -16
  42. package/src/fields/field-collapsible.svelte +49 -0
  43. package/src/fields/field-layout.svelte +2 -18
  44. package/src/fields/field-messages.svelte +13 -18
  45. package/src/fields/field-renderer.svelte +53 -14
  46. package/src/fields/join/join-field.svelte +8 -1
  47. package/src/fields/live-validation-feedback.svelte +21 -0
  48. package/src/fields/local-field-editor.svelte +52 -0
  49. package/src/fields/nested/nested-field.svelte +159 -91
  50. package/src/fields/nested/scoped-field.ts +7 -9
  51. package/src/fields/plugin-field.svelte +156 -0
  52. package/src/fields/point/point-field.svelte +12 -2
  53. package/src/fields/primitive-list/primitive-list-field.svelte +170 -0
  54. package/src/fields/primitive-list/primitive-number-input.ts +6 -0
  55. package/src/fields/radio/radio-field.svelte +13 -9
  56. package/src/fields/relationship/relationship-field.svelte +36 -12
  57. package/src/fields/relationship/relationship-query.ts +4 -2
  58. package/src/fields/relationship/relationship-quick-picker.svelte +38 -10
  59. package/src/fields/scalar/date-control-value.ts +5 -5
  60. package/src/fields/scalar/date-value.ts +5 -5
  61. package/src/fields/scalar/scalar-field.svelte +47 -41
  62. package/src/fields/select/select-field.svelte +24 -24
  63. package/src/fields/select/select-value.ts +3 -3
  64. package/src/fields/text/slug-field.svelte +24 -8
  65. package/src/fields/text/text-field.svelte +8 -6
  66. package/src/index.ts +4 -12
@@ -1,5 +1,8 @@
1
1
  <script lang="ts">
2
+ import { resolveBlockTypes } from "@riducms/protocol";
3
+
2
4
  import type { SchemaField } from "@riducms/protocol";
5
+ import { fieldControlARIA } from "@riducms/ui";
3
6
  import { DragDropProvider, type DragDropEvents } from "@dnd-kit-svelte/svelte";
4
7
  import ChevronDownIcon from "~icons/lucide/chevron-down";
5
8
  import CirclePlusIcon from "~icons/lucide/circle-plus";
@@ -28,10 +31,12 @@
28
31
  import { initialFormValues } from "@admin/core/forms/form-schema";
29
32
  import { immutableRowLabelSnapshot } from "@admin/core/plugins/row-label-registry";
30
33
  import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
34
+ import { focusFieldIssue, fieldIssueRevealEvent } from "@admin/core/forms/field-issue-focus";
31
35
  import FieldLayout from "@admin/fields/field-layout.svelte";
32
36
  import FieldMessages from "@admin/fields/field-messages.svelte";
33
37
  import {
34
38
  compatibleClipboardValue,
39
+ cloneFieldForPaste,
35
40
  createFieldClipboardPayload,
36
41
  readFieldClipboard,
37
42
  writeFieldClipboard,
@@ -44,18 +49,32 @@
44
49
  const customRowLabel = $derived(runtime.rowLabels.resolve(field));
45
50
  const rows = $derived((form.get(field.path) as Record<string, unknown>[] | undefined) ?? []);
46
51
  const issues = $derived(form.issuesFor(field.path).filter((issue) => issue.path === field.path));
47
- const hasMessage = $derived(issues.length > 0 || field.admin.description !== undefined);
48
- const minRows = $derived(field.nested?.minRows ?? 0);
49
- const maxRows = $derived(field.nested?.maxRows ?? 0);
50
- const readOnly = $derived(field.admin.readOnly === true);
51
- const canAdd = $derived(!readOnly && (maxRows === 0 || rows.length < maxRows));
52
+ const controlARIA = $derived(
53
+ fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
54
+ );
55
+ const minRows = $derived(
56
+ (field.type === "blocks" ? field.blocks?.minRows : field.nested?.minRows) ?? 0
57
+ );
58
+ const maxRows = $derived(
59
+ (field.type === "blocks" ? field.blocks?.maxRows : field.nested?.maxRows) ?? 0
60
+ );
61
+ const unknownRows = $derived(
62
+ field.type === "blocks" &&
63
+ rows.some(
64
+ (row) => !resolveBlockTypes(field.blocks).some((block) => block.slug === row.blockType)
65
+ )
66
+ );
67
+ const readOnly = $derived(field.admin.readOnly === true || unknownRows);
68
+ const editingBlocked = $derived(readOnly || form.editingBlocked);
69
+ const canAdd = $derived(!editingBlocked && (maxRows === 0 || rows.length < maxRows));
52
70
  let collapsed = $state(new Set<string>());
53
71
  let clipboardMessage = $state("");
54
72
  let blockPickerOpen = $state(false);
55
73
  let blockQuery = $state("");
74
+ let insertAfter = $state<string>();
56
75
  const visibleBlockTypes = $derived(
57
- (field.blocks?.types ?? []).filter((block) =>
58
- block.label
76
+ (resolveBlockTypes(field.blocks) ?? []).filter((block) =>
77
+ block.labels.singular
59
78
  .toLocaleLowerCase(runtime.i18n.language)
60
79
  .includes(blockQuery.trim().toLocaleLowerCase(runtime.i18n.language))
61
80
  )
@@ -63,7 +82,10 @@
63
82
 
64
83
  function changeBlockPickerOpen(open: boolean) {
65
84
  blockPickerOpen = open;
66
- if (!open) blockQuery = "";
85
+ if (!open) {
86
+ blockQuery = "";
87
+ insertAfter = undefined;
88
+ }
67
89
  }
68
90
 
69
91
  function rowField(child: SchemaField, index: number, rowKey: string) {
@@ -79,7 +101,7 @@
79
101
  function createRow(blockType?: string) {
80
102
  const children =
81
103
  field.type === "blocks"
82
- ? (field.blocks?.types.find((block) => block.key === blockType)?.fields ?? [])
104
+ ? (resolveBlockTypes(field.blocks).find((block) => block.slug === blockType)?.fields ?? [])
83
105
  : (field.nested?.fields ?? []);
84
106
  return {
85
107
  ...initialFormValues(children),
@@ -89,25 +111,32 @@
89
111
  }
90
112
 
91
113
  function add(blockType?: string) {
92
- if (readOnly || !canAdd) return;
114
+ if (editingBlocked || !canAdd) return;
93
115
  if (field.type === "blocks" && blockType === undefined) {
94
116
  blockPickerOpen = true;
95
117
  return;
96
118
  }
97
- updateRows([...rows, createRow(blockType)]);
119
+ const next = [...rows];
120
+ const index =
121
+ insertAfter === undefined
122
+ ? rows.length
123
+ : rows.findIndex((row) => row._key === insertAfter) + 1;
124
+ next.splice(index, 0, createRow(blockType));
125
+ updateRows(next);
126
+ insertAfter = undefined;
98
127
  blockPickerOpen = false;
99
128
  blockQuery = "";
100
129
  }
101
130
 
102
131
  function addBelow(index: number) {
103
- if (readOnly || !canAdd) return;
104
- const blockType =
105
- field.type === "blocks"
106
- ? String(rows[index]?.blockType ?? field.blocks?.types[0]?.key ?? "")
107
- : undefined;
108
- const row = createRow(blockType);
132
+ if (editingBlocked || !canAdd) return;
133
+ if (field.type === "blocks") {
134
+ insertAfter = String(rows[index]?._key);
135
+ blockPickerOpen = true;
136
+ return;
137
+ }
109
138
  const next = [...rows];
110
- next.splice(index + 1, 0, row);
139
+ next.splice(index + 1, 0, createRow());
111
140
  updateRows(next);
112
141
  }
113
142
 
@@ -117,7 +146,7 @@
117
146
  }
118
147
 
119
148
  async function pasteField() {
120
- if (readOnly) return;
149
+ if (editingBlocked) return;
121
150
  const value = compatibleClipboardValue(await readFieldClipboard(), field, "field");
122
151
  if (value === undefined) {
123
152
  clipboardMessage = runtime.i18n.t("errors:clipboardField");
@@ -162,7 +191,7 @@
162
191
  }
163
192
 
164
193
  async function pasteRow(index: number) {
165
- if (readOnly || !canAdd) return;
194
+ if (editingBlocked || !canAdd) return;
166
195
  const value = compatibleClipboardValue(await readFieldClipboard(), field, "row");
167
196
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
168
197
  clipboardMessage = runtime.i18n.t("errors:clipboardRow");
@@ -172,7 +201,7 @@
172
201
  row._key = crypto.randomUUID();
173
202
  if (
174
203
  field.type === "blocks" &&
175
- !field.blocks?.types.some((block) => block.key === row.blockType)
204
+ !resolveBlockTypes(field.blocks).some((block) => block.slug === row.blockType)
176
205
  ) {
177
206
  clipboardMessage = runtime.i18n.t("errors:clipboardBlockType");
178
207
  return;
@@ -184,22 +213,24 @@
184
213
  }
185
214
 
186
215
  function updateRows(next: Record<string, unknown>[]) {
187
- if (readOnly) return;
188
- form.invalidateIndexedFieldCapabilities(field.path);
189
- form.set(field.path, next);
216
+ if (editingBlocked) return;
217
+ form.setRows(field.path, next);
190
218
  }
191
219
 
192
220
  function duplicate(index: number) {
193
- if (readOnly || !canAdd) return;
194
- const copy = JSON.parse(JSON.stringify(rows[index] ?? {})) as Record<string, unknown>;
195
- copy._key = crypto.randomUUID();
221
+ if (editingBlocked || !canAdd) return;
222
+ const copy = cloneFieldForPaste(field, rows[index], "row") as Record<string, unknown>;
196
223
  const next = [...rows];
197
224
  next.splice(index + 1, 0, copy);
198
225
  updateRows(next);
199
226
  }
200
227
 
201
228
  function rowLabel(row: Record<string, unknown>, index: number) {
202
- const key = field.nested?.rowLabel;
229
+ const key =
230
+ field.type === "blocks"
231
+ ? resolveBlockTypes(field.blocks).find((block) => block.slug === row.blockType)?.admin
232
+ ?.rowLabel
233
+ : field.nested?.rowLabel;
203
234
  const configured = key === undefined ? undefined : row[key];
204
235
  if (configured !== undefined && configured !== null && String(configured).trim() !== "") {
205
236
  return String(configured);
@@ -215,7 +246,8 @@
215
246
 
216
247
  function visibleRowLabel(row: Record<string, unknown>, index: number) {
217
248
  if (field.type !== "blocks") return rowLabel(row, index);
218
- const key = field.nested?.rowLabel;
249
+ const key = resolveBlockTypes(field.blocks).find((block) => block.slug === row.blockType)?.admin
250
+ ?.rowLabel;
219
251
  const configured = key === undefined ? undefined : row[key];
220
252
  return configured === undefined || configured === null || String(configured).trim() === ""
221
253
  ? runtime.i18n.t("fields:untitled", {
@@ -226,7 +258,20 @@
226
258
 
227
259
  function blockLabel(row: Record<string, unknown>) {
228
260
  if (field.type !== "blocks") return undefined;
229
- return field.blocks?.types.find((block) => block.key === row.blockType)?.label;
261
+ return (
262
+ resolveBlockTypes(field.blocks).find((block) => block.slug === row.blockType)?.labels
263
+ .singular ?? (typeof row.blockType === "string" ? row.blockType : undefined)
264
+ );
265
+ }
266
+
267
+ function revealRow(key: string) {
268
+ return (element: HTMLElement) => {
269
+ const reveal = () => {
270
+ if (collapsed.has(key)) toggleCollapsed(key);
271
+ };
272
+ element.addEventListener(fieldIssueRevealEvent, reveal);
273
+ return () => element.removeEventListener(fieldIssueRevealEvent, reveal);
274
+ };
230
275
  }
231
276
 
232
277
  function toggleCollapsed(key: string) {
@@ -245,7 +290,9 @@
245
290
 
246
291
  function fieldsFor(row: Record<string, unknown>) {
247
292
  if (field.type !== "blocks") return field.nested?.fields ?? [];
248
- return field.blocks?.types.find((block) => block.key === row.blockType)?.fields ?? [];
293
+ return (
294
+ resolveBlockTypes(field.blocks).find((block) => block.slug === row.blockType)?.fields ?? []
295
+ );
249
296
  }
250
297
 
251
298
  function fieldsForRow(row: Record<string, unknown>, index: number) {
@@ -254,7 +301,7 @@
254
301
  }
255
302
 
256
303
  function reorder(event: Parameters<DragDropEvents["dragend"]>[0]) {
257
- if (readOnly) return;
304
+ if (editingBlocked) return;
258
305
  const source = String(event.operation.source?.id ?? "");
259
306
  const target = String(event.operation.target?.id ?? "");
260
307
  const from = rows.findIndex((row) => row._key === source);
@@ -279,7 +326,7 @@
279
326
  <DropdownMenuItem onSelect={copyField}>
280
327
  {runtime.i18n.t("fields:copyField")}
281
328
  </DropdownMenuItem>
282
- <DropdownMenuItem disabled={field.admin.readOnly} onSelect={pasteField}>
329
+ <DropdownMenuItem disabled={editingBlocked} onSelect={pasteField}>
283
330
  {runtime.i18n.t("fields:pasteField")}
284
331
  </DropdownMenuItem>
285
332
  </DropdownMenuContent>
@@ -296,25 +343,26 @@
296
343
  data-field-path={field.path}
297
344
  data-invalid={issues.length > 0}
298
345
  aria-labelledby={`${field.id}-label`}
299
- aria-describedby={hasMessage ? `${field.id}-message` : undefined}
346
+ {...controlARIA}
300
347
  >
301
- <div class="flex items-start justify-between gap-3 border-b border-control-border pb-2.5">
302
- <div class="min-w-0">
303
- <h2 id={`${field.id}-label`} class="text-[17px] font-medium text-foreground">
304
- {field.admin.label}{#if field.required}<span
305
- class="ridu-field-required"
306
- aria-hidden="true"
307
- >
308
- *
348
+ <FieldMessages controlID={field.id} {issues} description={field.admin.description}>
349
+ <div class="flex items-start justify-between gap-3 border-b border-control-border pb-2.5">
350
+ <div class="min-w-0">
351
+ <h2 id={`${field.id}-label`} class="text-[17px] font-medium text-foreground">
352
+ {field.admin.label}{#if field.required}<span
353
+ class="ridu-field-required"
354
+ aria-hidden="true"
355
+ >
356
+ *
357
+ </span>{/if}
358
+ </h2>
359
+ {#if field.admin.readOnly}<span class="ridu-field-status mt-1">
360
+ {runtime.i18n.t("fields:readOnly")}
309
361
  </span>{/if}
310
- </h2>
311
- {#if field.admin.readOnly}<span class="ridu-field-status mt-1">
312
- {runtime.i18n.t("fields:readOnly")}
313
- </span>{/if}
362
+ </div>
363
+ {@render fieldActions()}
314
364
  </div>
315
- {@render fieldActions()}
316
- </div>
317
- <FieldMessages id="{field.id}-message" {issues} description={field.admin.description} />
365
+ </FieldMessages>
318
366
  <FieldLayout fields={inheritedFields} {form} />
319
367
  {#if clipboardMessage !== ""}<p
320
368
  class="font-mono text-[10px] text-foreground-faint"
@@ -329,42 +377,47 @@
329
377
  data-field-path={field.path}
330
378
  data-invalid={issues.length > 0}
331
379
  aria-labelledby={`${field.id}-label`}
332
- aria-describedby={hasMessage ? `${field.id}-message` : undefined}
380
+ {...controlARIA}
333
381
  >
334
- <div class="flex flex-wrap items-end justify-between gap-3">
335
- <div class="min-w-0">
336
- <h2 id={`${field.id}-label`} class="text-[19px] leading-7 font-medium text-foreground">
337
- {field.admin.label}{#if field.required}<span
338
- class="ridu-field-required"
339
- aria-hidden="true"
340
- >
341
- *
382
+ <FieldMessages controlID={field.id} {issues} description={field.admin.description}>
383
+ <div class="flex flex-wrap items-end justify-between gap-3">
384
+ <div class="min-w-0">
385
+ <h2 id={`${field.id}-label`} class="text-[19px] leading-7 font-medium text-foreground">
386
+ {field.admin.label}{#if field.required}<span
387
+ class="ridu-field-required"
388
+ aria-hidden="true"
389
+ >
390
+ *
391
+ </span>{/if}
392
+ </h2>
393
+ {#if field.admin.readOnly}<span class="ridu-field-status mt-1">
394
+ {runtime.i18n.t("fields:readOnly")}
342
395
  </span>{/if}
343
- </h2>
344
- {#if field.admin.readOnly}<span class="ridu-field-status mt-1">
345
- {runtime.i18n.t("fields:readOnly")}
346
- </span>{/if}
347
- </div>
348
- <div class="flex items-center gap-1">
349
- {#if rows.length > 0}
350
- <Button size="xs" variant="ghost" class="px-1.5" onclick={collapseAll}>
351
- {runtime.i18n.t("fields:collapseAll")}
352
- </Button>
353
- <Button size="xs" variant="ghost" class="px-1.5" onclick={showAll}>
354
- {runtime.i18n.t("fields:showAll")}
355
- </Button>
356
- {/if}
357
- {@render fieldActions()}
396
+ </div>
397
+ <div class="flex items-center gap-1">
398
+ {#if rows.length > 0}
399
+ <Button size="xs" variant="ghost" class="px-1.5" onclick={collapseAll}>
400
+ {runtime.i18n.t("fields:collapseAll")}
401
+ </Button>
402
+ <Button size="xs" variant="ghost" class="px-1.5" onclick={showAll}>
403
+ {runtime.i18n.t("fields:showAll")}
404
+ </Button>
405
+ {/if}
406
+ {@render fieldActions()}
407
+ </div>
358
408
  </div>
359
- </div>
360
- <FieldMessages id="{field.id}-message" {issues} description={field.admin.description} />
409
+ </FieldMessages>
410
+ {#if unknownRows}<p role="alert" class="text-sm text-destructive">
411
+ {runtime.i18n.t("errors:unknownBlockRecovery")}
412
+ </p>{/if}
361
413
  <DragDropProvider onDragEnd={reorder}>
362
414
  <div class="grid gap-3">
363
- {#each rows as row, index (String(row._key))}
415
+ {#each rows as row, index (form.rowMountKey(row))}
364
416
  {const rowKey = String(row._key)}
365
417
  {const rowCollapsed = $derived(collapsed.has(rowKey))}
418
+ {const rowIssues = $derived(form.issuesFor(`${field.path}.${index}`))}
366
419
  {const rowSnapshot = $derived(immutableRowLabelSnapshot(row))}
367
- <SortableRow id={String(row._key)} {index} disabled={readOnly}>
420
+ <SortableRow id={String(row._key)} {index} disabled={editingBlocked}>
368
421
  {#snippet children(sortable)}
369
422
  <div class="flex min-h-10 items-center justify-between gap-2 bg-control px-3 py-2">
370
423
  <Button
@@ -374,7 +427,7 @@
374
427
  aria-label={runtime.i18n.t("fields:drag", {
375
428
  label: rowLabel(row, index),
376
429
  })}
377
- disabled={readOnly}
430
+ disabled={editingBlocked}
378
431
  {@attach sortable.handleRef}
379
432
  >
380
433
  <GripVerticalIcon class="size-3.5 text-foreground-faint" />
@@ -403,7 +456,9 @@
403
456
  row={rowSnapshot}
404
457
  rowNumber={index + 1}
405
458
  i18n={runtime.i18n}
406
- config={field.nested?.rowLabelComponent?.config}
459
+ {...customRowLabel.config === undefined
460
+ ? {}
461
+ : { config: customRowLabel.config }}
407
462
  />
408
463
  {/if}
409
464
  </div>
@@ -437,7 +492,7 @@
437
492
  </DropdownMenuTrigger>
438
493
  <DropdownMenuContent>
439
494
  <DropdownMenuItem
440
- disabled={readOnly || index === 0}
495
+ disabled={editingBlocked || index === 0}
441
496
  onSelect={() => {
442
497
  const next = [...rows];
443
498
  [next[index - 1], next[index]] = [next[index], next[index - 1]];
@@ -447,7 +502,7 @@
447
502
  {runtime.i18n.t("fields:moveUp")}
448
503
  </DropdownMenuItem>
449
504
  <DropdownMenuItem
450
- disabled={readOnly || index === rows.length - 1}
505
+ disabled={editingBlocked || index === rows.length - 1}
451
506
  onSelect={() => {
452
507
  const next = [...rows];
453
508
  [next[index], next[index + 1]] = [next[index + 1], next[index]];
@@ -457,7 +512,7 @@
457
512
  {runtime.i18n.t("fields:moveDown")}
458
513
  </DropdownMenuItem>
459
514
  <DropdownMenuItem
460
- disabled={readOnly || !canAdd}
515
+ disabled={editingBlocked || !canAdd}
461
516
  onSelect={() => addBelow(index)}
462
517
  >
463
518
  {runtime.i18n.t("fields:addBelow")}
@@ -467,13 +522,13 @@
467
522
  {runtime.i18n.t("fields:copyRow")}
468
523
  </DropdownMenuItem>
469
524
  <DropdownMenuItem
470
- disabled={readOnly || !canAdd}
525
+ disabled={editingBlocked || !canAdd}
471
526
  onSelect={() => pasteRow(index)}
472
527
  >
473
528
  {runtime.i18n.t("fields:pasteRow")}
474
529
  </DropdownMenuItem>
475
530
  <DropdownMenuItem
476
- disabled={readOnly || !canAdd}
531
+ disabled={editingBlocked || !canAdd}
477
532
  onSelect={() => duplicate(index)}
478
533
  >
479
534
  {runtime.i18n.t("fields:duplicate")}
@@ -481,7 +536,8 @@
481
536
  <DropdownMenuSeparator />
482
537
  <DropdownMenuItem
483
538
  class="text-destructive data-[highlighted]:text-destructive"
484
- disabled={readOnly || rows.length <= minRows}
539
+ disabled={editingBlocked ||
540
+ rows.length <= Math.max(minRows, field.required ? 1 : 0)}
485
541
  onSelect={() =>
486
542
  updateRows(rows.filter((_, rowIndex) => rowIndex !== index))}
487
543
  >
@@ -491,7 +547,19 @@
491
547
  </DropdownMenu>
492
548
  </div>
493
549
  </div>
550
+ {#if rowIssues.length > 0}
551
+ <button
552
+ type="button"
553
+ class="px-4 py-2 text-start text-sm text-destructive underline"
554
+ onclick={() => focusFieldIssue(rowIssues[0].path)}
555
+ >
556
+ {runtime.i18n.t("fields:rowErrors", { count: rowIssues.length })}: {rowIssues[0]
557
+ .message}
558
+ </button>
559
+ {/if}
494
560
  <div
561
+ {@attach revealRow(rowKey)}
562
+ data-field-path={`${field.path}.${index}`}
495
563
  id={`${field.id}-${rowKey}-content`}
496
564
  class={[
497
565
  "grid transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",
@@ -504,7 +572,7 @@
504
572
  >
505
573
  <div class="min-h-0 overflow-hidden">
506
574
  <div class="grid gap-5 p-4">
507
- <FieldLayout fields={fieldsForRow(row, index)} {form} />
575
+ {#if !rowCollapsed}<FieldLayout fields={fieldsForRow(row, index)} {form} />{/if}
508
576
  </div>
509
577
  </div>
510
578
  </div>
@@ -517,7 +585,7 @@
517
585
  variant="ghost"
518
586
  size="sm"
519
587
  class="w-fit px-0 text-foreground-faint hover:bg-transparent hover:text-foreground"
520
- disabled={field.admin.readOnly || !canAdd}
588
+ disabled={editingBlocked || !canAdd}
521
589
  onclick={() => add()}
522
590
  >
523
591
  <CirclePlusIcon class="size-5" />
@@ -527,7 +595,7 @@
527
595
  ? runtime.i18n.t("fields:addRow")
528
596
  : runtime.i18n.t("fields:add", { label: field.nested.rowLabels.singular })}
529
597
  </Button>
530
- {#if field.type === "array" && (minRows > 0 || maxRows > 0)}
598
+ {#if minRows > 0 || maxRows > 0}
531
599
  <p class="font-mono text-[10px] text-foreground-faint">
532
600
  {runtime.i18n.t("fields:rowLimits", {
533
601
  count: rows.length,
@@ -579,11 +647,11 @@
579
647
  </p>
580
648
  {:else}
581
649
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
582
- {#each visibleBlockTypes as block (block.key)}
650
+ {#each visibleBlockTypes as block (block.slug)}
583
651
  <button
584
652
  type="button"
585
653
  class="group overflow-hidden rounded-[3px] border border-control-border bg-background text-start transition-colors hover:border-control-border-hover focus-visible:border-ring focus-visible:outline-none"
586
- onclick={() => add(block.key)}
654
+ onclick={() => add(block.slug)}
587
655
  >
588
656
  <span
589
657
  class="flex aspect-video items-center justify-center border-b border-control-border bg-control text-foreground-faint transition-colors group-hover:bg-control-hover group-hover:text-foreground-muted"
@@ -591,7 +659,7 @@
591
659
  <LayoutTemplateIcon class="size-8" />
592
660
  </span>
593
661
  <span class="block px-3 py-2.5 text-[13px] font-medium text-foreground">
594
- {block.label}
662
+ {block.labels.singular}
595
663
  </span>
596
664
  </button>
597
665
  {/each}
@@ -1,3 +1,4 @@
1
+ import { mapBlockTypes } from "@riducms/protocol";
1
2
  import type { SchemaField } from "@riducms/protocol";
2
3
 
3
4
  const canonicalAccessPath = Symbol.for("@riducms/admin/canonicalAccessPath");
@@ -49,15 +50,12 @@ function scopeField(
49
50
  ...(field.blocks === undefined
50
51
  ? {}
51
52
  : {
52
- blocks: {
53
- ...field.blocks,
54
- types: field.blocks.types.map((block) => ({
55
- ...block,
56
- fields: block.fields.map((child) =>
57
- scopeField(child, `${path}.${child.name}`, instance, readOnly)
58
- ),
59
- })),
60
- },
53
+ blocks: mapBlockTypes(field.blocks, (block) => ({
54
+ ...block,
55
+ fields: block.fields.map((child) =>
56
+ scopeField(child, `${path}.${child.name}`, instance, readOnly)
57
+ ),
58
+ })),
61
59
  }),
62
60
  };
63
61
  return scoped;
@@ -0,0 +1,156 @@
1
+ <script lang="ts">
2
+ import { onDestroy, type Component } from "svelte";
3
+ import type {
4
+ EmbeddedSchemaDraft,
5
+ EmbeddedSchemaDraftEditorProps,
6
+ EmbeddedSchemaFormScope,
7
+ FieldAuthoringHost,
8
+ PluginFieldProps,
9
+ ResolvedPluginField,
10
+ } from "@riducms/plugin";
11
+ import type { SchemaField, FieldType } from "@riducms/protocol";
12
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
13
+ import { PluginFieldBinding } from "@admin/core/forms/plugin-field-binding";
14
+ import { guardPluginAuthoring } from "@admin/core/forms/plugin-field-authoring";
15
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
16
+ import { localizeSchemaCollection } from "@admin/core/i18n/localized-schema";
17
+ import { embeddedOccurrences } from "@admin/core/forms/embedded-fields";
18
+ import {
19
+ createEmbeddedSchemaDraft,
20
+ copyEmbeddedSchemaPayload,
21
+ type HostedSchemaDraft,
22
+ } from "@admin/core/forms/embedded-schema-draft.svelte";
23
+ import EmbeddedSchemaFields from "@admin/fields/embedded-schema-fields.svelte";
24
+ import EmbeddedSchemaDraftEditor from "@admin/fields/embedded-schema-draft-editor.svelte";
25
+ import ReferenceBrowser from "@admin/features/reference-browser/reference-browser-loader.svelte";
26
+ let {
27
+ schema,
28
+ form,
29
+ extension,
30
+ }: { schema: SchemaField; form: FormController; extension: ResolvedPluginField } = $props();
31
+ const runtime = getAdminRuntime();
32
+ // This host is keyed by form, baseline, manifest and logical field identity.
33
+ // svelte-ignore state_referenced_locally
34
+ const config = extension.registration.decodeConfig(schema);
35
+ // svelte-ignore state_referenced_locally
36
+ const field = new PluginFieldBinding(
37
+ form,
38
+ () => schema,
39
+ extension.registration,
40
+ () => runtime.manifestRevision
41
+ );
42
+ // Registration validates correlation; component erasure is recovered only at this render boundary.
43
+ // svelte-ignore state_referenced_locally
44
+ const Editor = extension.registration.component as Component<
45
+ Omit<PluginFieldProps<unknown, unknown, FieldType>, "config"> & { config?: unknown }
46
+ >;
47
+ onDestroy(field.destroy);
48
+ const drafts = new Map<EmbeddedSchemaDraft, HostedSchemaDraft>();
49
+ const draftAliases = new WeakMap<EmbeddedSchemaDraft, EmbeddedSchemaDraft>();
50
+ onDestroy(() => {
51
+ for (const draft of drafts.keys()) draft.discard();
52
+ });
53
+ const authoring: FieldAuthoringHost = {
54
+ schemaForm,
55
+ schemaDraftEditor,
56
+ beginSchemaDraft: (scope) => {
57
+ const session = createEmbeddedSchemaDraft(
58
+ form,
59
+ () => field.schema,
60
+ scope,
61
+ runtime.i18n,
62
+ () => !field.stale
63
+ );
64
+ const discard = session.draft.discard;
65
+ session.draft.discard = () => {
66
+ discard();
67
+ drafts.delete(session.draft);
68
+ };
69
+ drafts.set(session.draft, session);
70
+ return session.draft;
71
+ },
72
+ schemaIssues: (scope) => {
73
+ const occurrence = embeddedIndex.get(JSON.stringify([scope.treeKey, scope.identity]));
74
+ return occurrence === undefined ? [] : form.issuesFor(occurrence.path);
75
+ },
76
+ copySchemaPayload: (scope, payload) => copyEmbeddedSchemaPayload(field.schema, scope, payload),
77
+ get collections() {
78
+ return (
79
+ runtime.manifest?.collections.map((collection) =>
80
+ localizeSchemaCollection(collection, runtime.i18n)
81
+ ) ?? []
82
+ );
83
+ },
84
+ get documentRevision() {
85
+ return runtime.documentRevision;
86
+ },
87
+ get locale() {
88
+ return form.contentLocale;
89
+ },
90
+ referenceBrowser: ReferenceBrowser,
91
+ findDocument: (collection, id, signal) =>
92
+ runtime.client.find(collection, id, { signal, locale: form.contentLocale }),
93
+ async requestPlugin<Result>(path: string, body: unknown, signal?: AbortSignal) {
94
+ const owner = extension.owner;
95
+ if (owner === undefined) {
96
+ throw new Error("This field renderer does not have an owning plugin");
97
+ }
98
+ return runtime.client.requestPlugin<Result>(owner, path, body, { signal });
99
+ },
100
+ };
101
+ const embedded = $derived(embeddedOccurrences(field.schema, field.rawValue, field.schema.path));
102
+ const embeddedIndex = $derived(
103
+ new Map(
104
+ embedded.occurrences.map((occurrence) => [
105
+ JSON.stringify([occurrence.tree.key, occurrence.identity]),
106
+ occurrence,
107
+ ])
108
+ )
109
+ );
110
+
111
+ const guardedAuthoring = guardPluginAuthoring(authoring, field, (draft, raw) => {
112
+ draftAliases.set(draft, raw);
113
+ });
114
+ function draftOptions(options: EmbeddedSchemaDraftEditorProps) {
115
+ field.assertActive();
116
+ const session = drafts.get(draftAliases.get(options.draft) ?? options.draft);
117
+ if (!session) throw new Error("This draft belongs to another field host.");
118
+ return {
119
+ session,
120
+ options: {
121
+ ...options,
122
+ draft: session.draft,
123
+ onApply: (payload: Record<string, unknown>) => {
124
+ field.assertEditable();
125
+ if (options.draft.stale) throw new Error("This embedded schema draft is stale.");
126
+ options.onApply(payload);
127
+ },
128
+ onCancel: () => {
129
+ field.assertActive();
130
+ options.onCancel();
131
+ },
132
+ },
133
+ };
134
+ }
135
+ </script>
136
+
137
+ {#snippet schemaForm(scope: EmbeddedSchemaFormScope)}
138
+ <EmbeddedSchemaFields
139
+ field={field.schema}
140
+ {form}
141
+ {scope}
142
+ occurrence={embeddedIndex.get(JSON.stringify([scope.treeKey, scope.identity]))}
143
+ issues={embedded.issues}
144
+ />
145
+ {/snippet}
146
+ {#snippet schemaDraftEditor(options: EmbeddedSchemaDraftEditorProps)}
147
+ {const current = draftOptions(options)}
148
+ <EmbeddedSchemaDraftEditor session={current.session} options={current.options} />
149
+ {/snippet}
150
+ <Editor
151
+ {field}
152
+ {...config === undefined ? {} : { config }}
153
+ form={field.form}
154
+ authoring={guardedAuthoring}
155
+ i18n={runtime.i18n}
156
+ />