@riducms/admin 0.1.5 → 0.2.1

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,10 +1,15 @@
1
1
  <script lang="ts" module>
2
+ import { resolveBlockTypes } from "@riducms/protocol";
3
+
2
4
  import type { SchemaField } from "@riducms/protocol";
3
5
 
4
6
  function fieldUsesLocalization(field: SchemaField): boolean {
5
7
  if (field.localized === true) return true;
6
8
  if (field.nested?.fields.some(fieldUsesLocalization)) return true;
7
- return field.blocks?.types.some((block) => block.fields.some(fieldUsesLocalization)) === true;
9
+ return (
10
+ resolveBlockTypes(field.blocks).some((block) => block.fields.some(fieldUsesLocalization)) ===
11
+ true
12
+ );
8
13
  }
9
14
 
10
15
  function withContentLocale(path: string, locale: string | undefined) {
@@ -14,6 +19,7 @@
14
19
  </script>
15
20
 
16
21
  <script lang="ts">
22
+ import { focusFieldIssue } from "@admin/core/forms/field-issue-focus";
17
23
  import type { AdminDocumentExtensionHost } from "@riducms/plugin";
18
24
  import { Link, useBlocker, useLocation, useNavigate, useParams } from "@hvniel/svelte-router";
19
25
  import { tick, untrack } from "svelte";
@@ -242,6 +248,18 @@
242
248
  let previousLivePreviewRouteKey: string | undefined;
243
249
  let dismissedLock = $state<string>();
244
250
  const livePreview = $derived(collection?.admin.livePreview);
251
+ $effect(() => {
252
+ const state = location.state as { openLivePreview?: boolean } | null;
253
+ if (routeDocumentView !== "edit" || state?.openLivePreview !== true) return;
254
+ livePreviewOpen = true;
255
+ // API and edit views have separate route instances. Consume the transient
256
+ // intent on arrival so Back or reload cannot reopen a dismissed preview.
257
+ const { openLivePreview: _, ...remainingState } = state;
258
+ navigate(`${location.pathname}${location.search}${location.hash}`, {
259
+ replace: true,
260
+ state: remainingState,
261
+ });
262
+ });
245
263
  const creatingAuthUser = $derived(
246
264
  creating && !globalResource && collection?.capabilities.auth === true
247
265
  );
@@ -321,10 +339,13 @@
321
339
  }
322
340
 
323
341
  function toggleLivePreview() {
342
+ if (!livePreviewOpen && routeDocumentView === "api") {
343
+ navigate(editPath, { replace: true, state: { openLivePreview: true } });
344
+ return;
345
+ }
324
346
  livePreviewOpen = !livePreviewOpen;
325
347
  if (livePreviewOpen) {
326
348
  controller.documentView = "edit";
327
- navigate(editPath, { replace: true });
328
349
  }
329
350
  }
330
351
 
@@ -413,7 +434,7 @@
413
434
  const issuePath = controller.revealFirstIssue();
414
435
  if (issuePath === undefined) return;
415
436
  await tick();
416
- focusIssue(issuePath);
437
+ await focusFieldIssue(issuePath);
417
438
  }
418
439
 
419
440
  async function handleSave(event: Event) {
@@ -464,24 +485,6 @@
464
485
  return runtime.i18n.t("documents:passwordsDoNotMatch");
465
486
  return undefined;
466
487
  }
467
-
468
- function focusIssue(path: string) {
469
- const segments = path.split(".");
470
- while (segments.length > 0) {
471
- const candidate = segments.join(".");
472
- const container = document.querySelector<HTMLElement>(
473
- `[data-field-path="${CSS.escape(candidate)}"]`
474
- );
475
- if (container !== null) {
476
- container.scrollIntoView({ behavior: "smooth", block: "center" });
477
- container
478
- .querySelector<HTMLElement>("input, textarea, button, [tabindex]:not([tabindex='-1'])")
479
- ?.focus({ preventScroll: true });
480
- return;
481
- }
482
- segments.pop();
483
- }
484
- }
485
488
  </script>
486
489
 
487
490
  <section
@@ -634,14 +637,10 @@
634
637
  aria-busy={form.submitting}
635
638
  onclick={handleSave}
636
639
  >
637
- {form.submitting
638
- ? runtime.i18n.t("documents:saving")
639
- : runtime.i18n.t("documents:saveDraft")}
640
+ {runtime.i18n.t("documents:saveDraft")}
640
641
  </Button>
641
642
  <Button size="sm" disabled={!canSave} aria-busy={form.submitting} onclick={handlePublish}>
642
- {form.submitting
643
- ? runtime.i18n.t("documents:publishing")
644
- : runtime.i18n.t("documents:publish")}
643
+ {runtime.i18n.t("documents:publish")}
645
644
  </Button>
646
645
  {:else if creating && versionedCollection && !draftsCollection}
647
646
  <Button
@@ -650,9 +649,7 @@
650
649
  aria-busy={form.submitting}
651
650
  onclick={handlePublish}
652
651
  >
653
- {form.submitting
654
- ? runtime.i18n.t("documents:publishing")
655
- : runtime.i18n.t("documents:publish")}
652
+ {runtime.i18n.t("documents:publish")}
656
653
  </Button>
657
654
  {:else}
658
655
  <Button
@@ -662,19 +659,13 @@
662
659
  aria-busy={form.submitting}
663
660
  onclick={handleSave}
664
661
  >
665
- {form.submitting
666
- ? runtime.i18n.t(
667
- versionedCollection && currentStatus === "published"
668
- ? "documents:publishing"
669
- : "documents:saving"
670
- )
671
- : creating && draftsCollection
672
- ? runtime.i18n.t("documents:saveDraft")
673
- : versionedCollection
674
- ? currentStatus === "published"
675
- ? runtime.i18n.t("documents:publishChanges")
676
- : runtime.i18n.t("documents:saveDraft")
677
- : runtime.i18n.t("documents:save")}
662
+ {creating && draftsCollection
663
+ ? runtime.i18n.t("documents:saveDraft")
664
+ : versionedCollection
665
+ ? currentStatus === "published"
666
+ ? runtime.i18n.t("documents:publishChanges")
667
+ : runtime.i18n.t("documents:saveDraft")
668
+ : runtime.i18n.t("documents:save")}
678
669
  </Button>
679
670
  {/if}
680
671
  {#if !creating && versionedCollection && currentStatus === "draft" && canPublish}
@@ -812,6 +803,8 @@
812
803
  <Skeleton class="mt-5 h-10.5" />
813
804
  <Skeleton class="h-28" />
814
805
  </div>
806
+ {:else if !creating && currentDocument === undefined}
807
+ <!-- A failed read has no editable document; the recovery/error banner remains visible. -->
815
808
  {:else if documentView === "api"}
816
809
  <DocumentAPIView
817
810
  resourceSlug={slug}
@@ -1,3 +1,5 @@
1
+ import { resolveBlockTypes } from "@riducms/protocol";
2
+ import { connectDocumentLiveValidation } from "@admin/core/forms/live-validation.svelte";
1
3
  import type { SchemaCollection, SchemaField } from "@riducms/protocol";
2
4
  import { RiduError } from "@riducms/sdk";
3
5
 
@@ -90,6 +92,7 @@ export class ReferenceBrowserWorkflow {
90
92
 
91
93
  constructor(readonly options: ReferenceBrowserWorkflowOptions) {
92
94
  this.form = new FormController({}, options.runtime.i18n);
95
+ connectDocumentLiveValidation(this.form, options.runtime.client);
93
96
  this.#lookup = new RelationshipLookupController(options.runtime.client);
94
97
  this.form.setLocalization(options.locale);
95
98
  this.pageSize = options.collection.capabilities.upload ? 8 : 10;
@@ -134,6 +137,7 @@ export class ReferenceBrowserWorkflow {
134
137
  });
135
138
 
136
139
  $effect(() => () => {
140
+ this.form.disposeBindings();
137
141
  this.#editorLoad.abort();
138
142
  this.#editorAccess?.abort();
139
143
  this.#saveRequest?.abort();
@@ -225,8 +229,9 @@ export class ReferenceBrowserWorkflow {
225
229
 
226
230
  get canCommitSelection() {
227
231
  return (
228
- this.workingSelection.length !== this.options.selectedIDs.length ||
229
- this.workingSelection.some((id) => !this.options.selectedIDs.includes(id))
232
+ !this.options.readOnly &&
233
+ (this.workingSelection.length !== this.options.selectedIDs.length ||
234
+ this.workingSelection.some((id) => !this.options.selectedIDs.includes(id)))
230
235
  );
231
236
  }
232
237
 
@@ -271,11 +276,11 @@ export class ReferenceBrowserWorkflow {
271
276
  };
272
277
 
273
278
  commitSelection = async () => {
274
- if (this.committing) return;
279
+ if (this.committing || this.options.readOnly) return;
275
280
  this.committing = true;
276
281
  try {
277
282
  const committed = await this.options.onCommit([...this.workingSelection]);
278
- if (committed !== false) this.#closeBrowser();
283
+ if (committed !== false && !this.options.readOnly) this.#closeBrowser();
279
284
  } catch (cause) {
280
285
  this.options.notifications.error({
281
286
  title: this.options.runtime.i18n.t("reference:updateNotConfirmed"),
@@ -304,11 +309,12 @@ export class ReferenceBrowserWorkflow {
304
309
  openNewDocument = () => {
305
310
  if (this.form.submitting || !this.canCreateDocument) return;
306
311
  this.editorDocument = undefined;
312
+ this.form.setResource({ collection: this.options.collection.slug });
307
313
  const values = {
308
314
  ...initialFormValues(this.options.collection.fields),
309
315
  ...this.options.defaultValues,
310
316
  };
311
- this.form.reset(values);
317
+ this.form.reset(values, this.options.collection.fields);
312
318
  this.form.setAccess(undefined, "create");
313
319
  this.selectedFiles = undefined;
314
320
  this.newUserPassword = "";
@@ -338,6 +344,7 @@ export class ReferenceBrowserWorkflow {
338
344
  this.credentialIssue = this.#validateNewUserPassword();
339
345
  if (this.credentialIssue !== undefined) return false;
340
346
  const saved = await this.form.submit(this.validationFields, this.creating, async (values) => {
347
+ if (this.options.readOnly) throw new Error("This field is read-only.");
341
348
  if (this.editorDocument !== undefined) {
342
349
  return this.options.runtime.client.update(
343
350
  this.options.collection.slug,
@@ -376,7 +383,7 @@ export class ReferenceBrowserWorkflow {
376
383
  locale: this.options.locale,
377
384
  });
378
385
  });
379
- if (request.signal.aborted) return false;
386
+ if (request.signal.aborted || this.options.readOnly) return false;
380
387
  const wasCreating = this.editorDocument === undefined;
381
388
  this.editorDocument = saved;
382
389
  this.#lookup.remember(saved);
@@ -384,7 +391,10 @@ export class ReferenceBrowserWorkflow {
384
391
  ? [...new Set([...this.workingSelection, saved.id])]
385
392
  : [saved.id];
386
393
  this.options.runtime.documentsChanged();
387
- this.form.reset(documentFormValues(this.options.collection.fields, saved));
394
+ this.form.reset(
395
+ documentFormValues(this.options.collection.fields, saved),
396
+ this.options.collection.fields
397
+ );
388
398
  this.form.setLocalization(this.options.locale, saved._localization?.sources);
389
399
  this.options.notifications.success({
390
400
  title: wasCreating
@@ -402,7 +412,7 @@ export class ReferenceBrowserWorkflow {
402
412
  this.credentialIssue = undefined;
403
413
  return true;
404
414
  } catch (cause) {
405
- if (request.signal.aborted) return false;
415
+ if (request.signal.aborted || this.options.readOnly) return false;
406
416
  if (
407
417
  creatingUpload &&
408
418
  !(cause instanceof FormValidationError) &&
@@ -550,8 +560,12 @@ export class ReferenceBrowserWorkflow {
550
560
  }
551
561
 
552
562
  #openEditor(document: AdminDocument) {
563
+ this.form.setResource({ collection: this.options.collection.slug, id: document.id });
553
564
  this.editorDocument = document;
554
- this.form.reset(documentFormValues(this.options.collection.fields, document));
565
+ this.form.reset(
566
+ documentFormValues(this.options.collection.fields, document),
567
+ this.options.collection.fields
568
+ );
555
569
  this.form.setLocalization(this.options.locale, document._localization?.sources);
556
570
  this.form.setAccess(undefined, "update");
557
571
  this.selectedFiles = undefined;
@@ -612,6 +626,7 @@ export class ReferenceBrowserWorkflow {
612
626
  }
613
627
 
614
628
  #closeBrowser() {
629
+ this.form.disposeBindings();
615
630
  this.options.setOpen(false);
616
631
  this.options.onClose();
617
632
  }
@@ -645,11 +660,11 @@ function createCollectionFilters(collection: SchemaCollection, i18n: AdminRuntim
645
660
  const select =
646
661
  collection.fields.find((field) => field.name === "status" && field.select !== undefined) ??
647
662
  collection.fields.find((field) => field.select !== undefined);
648
- for (const choice of select?.select?.choices ?? []) {
663
+ for (const option of select?.select?.options ?? []) {
649
664
  result.push({
650
- key: choice.value,
651
- label: i18n.text(choice.label, choice.labelTranslations),
652
- filter: { field: select?.name ?? "", operator: "equals", value: choice.value },
665
+ key: option.value,
666
+ label: i18n.text(option.label, option.labelTranslations),
667
+ filter: { field: select?.name ?? "", operator: "equals", value: option.value },
653
668
  });
654
669
  }
655
670
  return result;
@@ -675,9 +690,9 @@ function scopeField(field: SchemaField, prefix: string): SchemaField {
675
690
  ? {}
676
691
  : {
677
692
  blocks: {
678
- types: field.blocks.types.map((block) => ({
693
+ types: resolveBlockTypes(field.blocks).map((block) => ({
679
694
  ...block,
680
- fields: block.fields.map((child) => scopeField(child, `${prefix}-${block.key}`)),
695
+ fields: block.fields.map((child) => scopeField(child, `${prefix}-${block.slug}`)),
681
696
  })),
682
697
  },
683
698
  }),