@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,7 +1,11 @@
1
+ import type { Component } from "svelte";
2
+ import type { RowLabelComponentProps } from "@riducms/plugin";
3
+ import type { AdminConfig } from "@riducms/plugin/admin";
1
4
  import type { RowLabelPlugin, RowLabelSnapshot, RowLabelValue } from "@riducms/plugin";
2
5
  import type { SchemaField } from "@riducms/protocol";
3
6
 
4
7
  export class RowLabelRegistry {
8
+ constructor(private readonly local: NonNullable<AdminConfig["rowLabels"]> = {}) {}
5
9
  #plugins = new Map<string, RowLabelPlugin>();
6
10
 
7
11
  register(plugin: RowLabelPlugin) {
@@ -12,22 +16,38 @@ export class RowLabelRegistry {
12
16
  this.#plugins.set(identity, plugin);
13
17
  }
14
18
 
15
- resolve(field: SchemaField): RowLabelPlugin | undefined {
19
+ resolve(
20
+ field: SchemaField
21
+ ): { component: Component<RowLabelComponentProps>; config: unknown } | undefined {
16
22
  const component = field.nested?.rowLabelComponent;
17
23
  if (component === undefined) return undefined;
18
- const identity = rowLabelIdentity(component.plugin, component.component);
24
+ if (component.reference !== undefined) {
25
+ const local = this.local[component.reference as `app:${string}`];
26
+ if (local === undefined)
27
+ throw new Error(
28
+ `No local row label ${component.reference} is registered for ${field.path}.`
29
+ );
30
+ return {
31
+ component: local.component as Component<RowLabelComponentProps>,
32
+ config: local.decode(field),
33
+ };
34
+ }
35
+ const identity = rowLabelIdentity(component.plugin ?? "", component.component ?? "");
19
36
  const plugin = this.#plugins.get(identity);
20
37
  if (plugin === undefined) {
21
38
  throw new Error(
22
39
  `No admin row label component can render field ${field.path} (plugin "${component.plugin}", component "${component.component}")`
23
40
  );
24
41
  }
25
- return plugin;
42
+ return { component: plugin.component, config: component.config };
26
43
  }
27
44
  }
28
45
 
29
- export function createRowLabelRegistry(plugins: readonly RowLabelPlugin[] = []): RowLabelRegistry {
30
- const registry = new RowLabelRegistry();
46
+ export function createRowLabelRegistry(
47
+ plugins: readonly RowLabelPlugin[] = [],
48
+ local: AdminConfig["rowLabels"] = {}
49
+ ): RowLabelRegistry {
50
+ const registry = new RowLabelRegistry(local);
31
51
  for (const plugin of plugins) registry.register(plugin);
32
52
  return registry;
33
53
  }
@@ -1,5 +1,6 @@
1
+ import { validateAdminConfig, type AdminConfig } from "@riducms/plugin/admin";
1
2
  import {
2
- resolveAdminPluginExtensions,
3
+ resolveAdminExtensions,
3
4
  type AdminDashboardPanel,
4
5
  type AdminLoginComponent,
5
6
  type AdminAccountComponent,
@@ -13,8 +14,7 @@ import {
13
14
  type AdminDocumentView,
14
15
  type AdminListCell,
15
16
  type AdminPlugin,
16
- type AdminPluginRoute,
17
- type FieldPlugin,
17
+ type AdminRoute,
18
18
  } from "@riducms/plugin";
19
19
  import type { AuthSession, OperationCapabilities, SchemaManifest } from "@riducms/protocol";
20
20
  import { RiduError } from "@riducms/sdk";
@@ -48,7 +48,7 @@ class StalePreferenceSessionError extends Error {
48
48
  }
49
49
 
50
50
  export class AdminRuntime {
51
- manifest = $state<SchemaManifest>();
51
+ manifest = $state.raw<SchemaManifest>();
52
52
  manifestRevision = $state(0);
53
53
  documentRevision = $state(0);
54
54
  session = $state<AuthSession<AdminDocument>>();
@@ -150,7 +150,7 @@ export class AdminRuntime {
150
150
  return true;
151
151
  }
152
152
 
153
- readonly pluginRoutes: readonly AdminPluginRoute[];
153
+ readonly pluginRoutes: readonly AdminRoute[];
154
154
  readonly dashboardPanels: readonly AdminDashboardPanel[];
155
155
  readonly loginComponents: readonly AdminLoginComponent[];
156
156
  readonly accountComponents: readonly AdminAccountComponent[];
@@ -169,14 +169,12 @@ export class AdminRuntime {
169
169
  constructor(
170
170
  readonly client: AdminClient,
171
171
  readonly plugins: readonly AdminPlugin[] = [],
172
- fieldPlugins: readonly FieldPlugin[] = [],
173
172
  languages?: readonly TranslationLanguage[],
174
- readonly fields: FieldRegistry = createCoreFieldRegistry([
175
- ...plugins.flatMap((plugin) => plugin.fields),
176
- ...fieldPlugins,
177
- ])
173
+ readonly fields: FieldRegistry = createCoreFieldRegistry(plugins),
174
+ readonly editors: NonNullable<AdminConfig["fields"]> = {},
175
+ readonly application: AdminConfig = {}
178
176
  ) {
179
- const extensions = resolveAdminPluginExtensions(plugins);
177
+ const extensions = resolveAdminExtensions(plugins, application);
180
178
  this.pluginRoutes = extensions.routes;
181
179
  this.dashboardPanels = extensions.dashboard;
182
180
  this.loginComponents = extensions.login;
@@ -190,8 +188,14 @@ export class AdminRuntime {
190
188
  this.listCells = extensions.listCells;
191
189
  this.documentActions = extensions.documentActions;
192
190
  this.documentViews = extensions.documentViews;
193
- this.rowLabels = createRowLabelRegistry(extensions.rowLabels);
194
- this.i18n = new AdminI18nController(client, () => this.session, languages, extensions.messages);
191
+ this.rowLabels = createRowLabelRegistry(extensions.rowLabels, application.rowLabels);
192
+ this.i18n = new AdminI18nController(
193
+ client,
194
+ () => this.session,
195
+ languages,
196
+ extensions.messages,
197
+ extensions.applicationMessages
198
+ );
195
199
  this.#applyTheme();
196
200
  if (typeof window !== "undefined") {
197
201
  window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
@@ -435,6 +439,10 @@ export class AdminRuntime {
435
439
  try {
436
440
  const manifest = await this.client.schema();
437
441
  if (request !== this.#manifestRequest) return;
442
+ validateAdminConfig(
443
+ { ...this.application, plugins: this.plugins, fields: this.editors },
444
+ manifest
445
+ );
438
446
  this.manifest = manifest;
439
447
  this.manifestRevision += 1;
440
448
  this.#configureContentLocale();
@@ -487,6 +495,10 @@ export class AdminRuntime {
487
495
  try {
488
496
  const manifest = await this.client.schema();
489
497
  if (request !== this.#manifestRequest) return;
498
+ validateAdminConfig(
499
+ { ...this.application, plugins: this.plugins, fields: this.editors },
500
+ manifest
501
+ );
490
502
  this.manifest = manifest;
491
503
  this.manifestRevision += 1;
492
504
  this.#configureContentLocale();
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { untrack } from "svelte";
3
+ import { connectDocumentLiveValidation } from "@admin/core/forms/live-validation.svelte";
2
4
  import { useNavigate } from "@hvniel/svelte-router";
3
5
  import PaletteIcon from "~icons/lucide/palette";
4
6
  import RotateCcwIcon from "~icons/lucide/rotate-ccw";
@@ -19,6 +21,8 @@
19
21
  const notifications = getNotificationCenter();
20
22
  const navigate = useNavigate();
21
23
  const form = new FormController({}, runtime.i18n);
24
+ connectDocumentLiveValidation(form, runtime.client);
25
+ $effect(() => () => form.disposeBindings());
22
26
  let loading = $state(true);
23
27
  let error = $state<string>();
24
28
  let profilePending = $state(false);
@@ -69,13 +73,15 @@
69
73
  const locale = contentLocale;
70
74
  if (slug === undefined || id === undefined) return;
71
75
  const abort = new AbortController();
72
- void load(slug, id, locale, abort.signal);
76
+ untrack(() => load(slug, id, locale, abort.signal));
73
77
  return () => abort.abort();
74
78
  });
75
79
 
76
80
  $effect(() => runtime.registerContentLocaleBlocker(() => form.dirty || form.submitting));
77
81
 
78
82
  async function load(slug: string, id: string, locale: string | undefined, signal: AbortSignal) {
83
+ form.setResource({ collection: slug, id });
84
+ form.setLocalization(locale);
79
85
  loading = true;
80
86
  error = undefined;
81
87
  try {
@@ -84,7 +90,7 @@
84
90
  runtime.client.collectionAccess(slug, { id, signal, locale }),
85
91
  ]);
86
92
  if (signal.aborted) return;
87
- form.reset(document);
93
+ form.reset(document, collection?.fields ?? []);
88
94
  form.setLocalization(locale, document._localization?.sources);
89
95
  form.setAccess(access, "update");
90
96
  } catch (cause) {
@@ -249,10 +255,9 @@
249
255
  class="justify-self-start"
250
256
  type="submit"
251
257
  disabled={profilePending || !form.dirty || form.access?.operations.update !== true}
258
+ aria-busy={form.submitting}
252
259
  >
253
- {profilePending
254
- ? runtime.i18n.t("account:savingProfile")
255
- : runtime.i18n.t("account:saveProfile")}
260
+ {runtime.i18n.t("account:saveProfile")}
256
261
  </Button>
257
262
  </form>
258
263
  {/if}
@@ -333,7 +333,7 @@
333
333
  <span class="ridu-field-label">{runtime.i18n.t("account:expiration")}</span>
334
334
  <DateValueControl
335
335
  id="api-key-expiry"
336
- appearance="dayAndTime"
336
+ appearance="date-time"
337
337
  value={apiKeyExpiry}
338
338
  label={runtime.i18n.t("account:expiration")}
339
339
  onValueChange={(next) => (apiKeyExpiry = next)}
@@ -36,8 +36,8 @@
36
36
  let wasOpen = $state(false);
37
37
  const field = $derived(fields.find((candidate) => candidate.name === fieldName));
38
38
  const fieldLabel = $derived(field?.admin.label ?? runtime.i18n.t("collections:chooseField"));
39
- const selectedChoiceLabel = $derived(
40
- field?.select?.choices.find((choice) => choice.value === value)?.label ??
39
+ const selectedOptionLabel = $derived(
40
+ field?.select?.options.find((option) => option.value === value)?.label ??
41
41
  (value === "" ? runtime.i18n.t("collections:clearValue") : value)
42
42
  );
43
43
 
@@ -104,13 +104,13 @@
104
104
  <Select type="single" {value} onValueChange={(next) => (value = next)}>
105
105
  <SelectTrigger class="w-full" aria-label={runtime.i18n.t("collections:value")}>
106
106
  <span class={value === "" ? "text-foreground-placeholder" : undefined}>
107
- {selectedChoiceLabel}
107
+ {selectedOptionLabel}
108
108
  </span>
109
109
  </SelectTrigger>
110
110
  <SelectContent>
111
111
  <SelectItem value="" label={runtime.i18n.t("collections:clearValue")} />
112
- {#each field.select?.choices ?? [] as choice (choice.value)}
113
- <SelectItem value={choice.value} label={choice.label} />
112
+ {#each field.select?.options ?? [] as option (option.value)}
113
+ <SelectItem value={option.value} label={option.label} />
114
114
  {/each}
115
115
  </SelectContent>
116
116
  </Select>
@@ -129,7 +129,7 @@
129
129
  {:else if field?.type === "date"}
130
130
  <DateValueControl
131
131
  id={`bulk-edit-${field.id}`}
132
- appearance={field.date?.pickerAppearance}
132
+ appearance={field.date?.format}
133
133
  {value}
134
134
  label={runtime.i18n.t("collections:value")}
135
135
  onValueChange={(next) => (value = next)}
@@ -1,4 +1,4 @@
1
- import type { AccessCapabilitiesEnvelope, Pagination, SchemaSelectChoice } from "@riducms/protocol";
1
+ import type { AccessCapabilitiesEnvelope, Pagination, SchemaSelectOption } from "@riducms/protocol";
2
2
  import type { AdminI18n } from "@riducms/translations";
3
3
 
4
4
  import type { AdminClient, AdminDocument } from "@admin/core/api/admin-client";
@@ -15,7 +15,7 @@ interface CollectionListControllerOptions {
15
15
  get titleField(): string | undefined;
16
16
  get status(): string;
17
17
  get statusField(): string | undefined;
18
- get statusChoices(): readonly SchemaSelectChoice[];
18
+ get statusOptions(): readonly SchemaSelectOption[];
19
19
  get versioned(): boolean;
20
20
  get trashOnly(): boolean;
21
21
  get folderField(): string | undefined;
@@ -92,7 +92,7 @@ export class CollectionListController {
92
92
  titleField: this.options.titleField,
93
93
  status: this.options.status,
94
94
  statusField: this.options.statusField,
95
- statusValues: this.options.statusChoices.map((choice) => choice.value),
95
+ statusValues: this.options.statusOptions.map((option) => option.value),
96
96
  trashOnly: this.options.trashOnly,
97
97
  folderField: this.options.folderField,
98
98
  folderID: this.options.folderID,
@@ -278,7 +278,7 @@ export class CollectionListController {
278
278
  };
279
279
 
280
280
  statusLabel = (value: string) => {
281
- const configured = this.options.statusChoices.find((choice) => choice.value === value)?.label;
281
+ const configured = this.options.statusOptions.find((option) => option.value === value)?.label;
282
282
  if (configured !== undefined) return configured;
283
283
  const normalized = value.toLocaleLowerCase(this.options.i18n.language);
284
284
  if (normalized === "published") return this.options.i18n.t("documents:published");
@@ -296,7 +296,7 @@ export class CollectionListController {
296
296
  updated = (document: AdminDocument) => formatUpdated(document.updatedAt, this.options.i18n);
297
297
  created = (document: AdminDocument) => formatUpdated(document.createdAt, this.options.i18n);
298
298
 
299
- choiceCount = (choice: SchemaSelectChoice) => this.statusCounts[choice.value];
299
+ optionCount = (option: SchemaSelectOption) => this.statusCounts[option.value];
300
300
 
301
301
  toggleDocument = (id: string, checked: boolean) => {
302
302
  if (!this.selectionControlsReady || !this.docs.some((document) => document.id === id)) return;
@@ -75,7 +75,7 @@
75
75
  function addFilter() {
76
76
  if (field === undefined) return;
77
77
  const filterValue = operator === "exists" ? value || "true" : value;
78
- if (operator !== "exists" && filterValue === "") return;
78
+ if (operator !== "exists" && filterValue === "" && field?.type !== "text-list") return;
79
79
  onFiltersChange([...filters, { field: field.path, operator, value: filterValue }]);
80
80
  value = "";
81
81
  }
@@ -163,21 +163,21 @@
163
163
  aria-label={i18n.t("collections:filterValue")}
164
164
  >
165
165
  <span class={value === "" ? "text-foreground-placeholder" : undefined}>
166
- {field.select?.choices.find((choice) => choice.value === value)?.label ??
166
+ {field.select?.options.find((option) => option.value === value)?.label ??
167
167
  i18n.t("collections:chooseValue")}
168
168
  </span>
169
169
  </SelectTrigger>
170
170
  <SelectContent>
171
171
  <SelectItem value="" label={i18n.t("collections:chooseValue")} />
172
- {#each field.select?.choices ?? [] as choice}
173
- <SelectItem value={choice.value} label={choice.label} />
172
+ {#each field.select?.options ?? [] as option}
173
+ <SelectItem value={option.value} label={option.label} />
174
174
  {/each}
175
175
  </SelectContent>
176
176
  </Select>
177
177
  {:else if field.type === "date"}
178
178
  <DateValueControl
179
179
  id={`collection-filter-${field.id}`}
180
- appearance={field.date?.pickerAppearance}
180
+ appearance={field.date?.format}
181
181
  {value}
182
182
  label={i18n.t("collections:filterValue")}
183
183
  size="toolbar"
@@ -186,7 +186,7 @@
186
186
  {:else}
187
187
  <Input
188
188
  aria-label={i18n.t("collections:filterValue")}
189
- type={field.type === "number" ? "number" : "text"}
189
+ type={field.type === "number" || field.type === "number-list" ? "number" : "text"}
190
190
  class="h-8"
191
191
  bind:value
192
192
  />
@@ -65,7 +65,7 @@
65
65
  const statusField = $derived(
66
66
  collection?.fields.find((field) => field.type === "select" && field.name === "status")
67
67
  );
68
- const statusChoices = $derived(statusField?.select?.choices ?? []);
68
+ const statusOptions = $derived(statusField?.select?.options ?? []);
69
69
  const searchParams = $derived(new URLSearchParams(location.search));
70
70
  const localization = $derived(runtime.manifest?.application.localization);
71
71
  const requestedLocale = $derived(searchParams.get("locale"));
@@ -95,7 +95,7 @@
95
95
  let referenceLabels = $state.raw<Record<string, string>>({});
96
96
  let searchInput = $state<HTMLInputElement | null>(null);
97
97
  const activeStatus = $derived(
98
- statusChoices.some((choice) => choice.value === requestedStatus) ? requestedStatus : ""
98
+ statusOptions.some((option) => option.value === requestedStatus) ? requestedStatus : ""
99
99
  );
100
100
  const bulkEditableFields = $derived(
101
101
  collection?.fields.filter(
@@ -183,8 +183,8 @@
183
183
  get statusField() {
184
184
  return statusField?.name;
185
185
  },
186
- get statusChoices() {
187
- return statusChoices;
186
+ get statusOptions() {
187
+ return statusOptions;
188
188
  },
189
189
  get versioned() {
190
190
  return collection?.capabilities.versions === true;
@@ -224,7 +224,7 @@
224
224
  statusColumnAvailable,
225
225
  } = $derived(controller);
226
226
  const {
227
- choiceCount,
227
+ optionCount,
228
228
  canReadField,
229
229
  deletePermanent,
230
230
  bulkDelete,
@@ -512,13 +512,12 @@
512
512
  return value === true ? runtime.i18n.t("general:yes") : runtime.i18n.t("general:no");
513
513
  }
514
514
  if (field.type === "select" || field.type === "radio") {
515
- return field.select?.choices.find((choice) => choice.value === value)?.label ?? String(value);
515
+ return field.select?.options.find((option) => option.value === value)?.label ?? String(value);
516
516
  }
517
517
  if (field.type === "array") return summarizeRows(value, "row");
518
518
  if (field.type === "blocks") return summarizeRows(value, "block");
519
519
  if (field.type === "json" || field.plugin !== undefined) return summarizeStructured(value);
520
- if (field.type === "date")
521
- return formatDateDisplay(value, field.date?.pickerAppearance, runtime.i18n);
520
+ if (field.type === "date") return formatDateDisplay(value, field.date?.format, runtime.i18n);
522
521
  if (referenceField(field)) {
523
522
  return runtime.i18n.formatList(
524
523
  fieldReferences(field, value).map(
@@ -769,7 +768,7 @@
769
768
  </div>
770
769
  </header>
771
770
 
772
- {#if statusChoices.length > 0 && !trashOnly}
771
+ {#if statusOptions.length > 0 && !trashOnly}
773
772
  <div
774
773
  class="mt-6 flex h-9 w-full items-center justify-start gap-5 border-b border-control-border"
775
774
  role="group"
@@ -786,15 +785,15 @@
786
785
  {runtime.i18n.formatNumber(allDocuments)}
787
786
  </span>
788
787
  </button>
789
- {#each statusChoices as choice (choice.value)}
790
- {const statusCount = $derived(choiceCount(choice))}
788
+ {#each statusOptions as option (option.value)}
789
+ {const statusCount = $derived(optionCount(option))}
791
790
  <button
792
791
  type="button"
793
792
  class="relative h-9 flex-none cursor-pointer px-px pb-2.5 text-[13px] text-foreground-muted outline-none after:absolute after:inset-x-0 after:bottom-[-1px] after:h-0.5 after:bg-primary after:opacity-0 after:transition-opacity hover:text-foreground-strong focus-visible:outline-2 focus-visible:outline-primary/60 focus-visible:outline-offset-2 aria-pressed:text-foreground-strong aria-pressed:after:opacity-100"
794
- aria-pressed={activeStatus === choice.value}
795
- onclick={() => selectStatus(choice.value)}
793
+ aria-pressed={activeStatus === option.value}
794
+ onclick={() => selectStatus(option.value)}
796
795
  >
797
- {choice.label}
796
+ {option.label}
798
797
  {#if statusCount !== undefined}
799
798
  <span class="font-mono ms-1 text-[9.5px] text-foreground-faint">
800
799
  {runtime.i18n.formatNumber(statusCount)}
@@ -1,3 +1,4 @@
1
+ import { resolveBlockTypes } from "@riducms/protocol";
1
2
  import type { SchemaCollection, SchemaField } from "@riducms/protocol";
2
3
  import type { AdminI18n } from "@riducms/translations";
3
4
 
@@ -13,7 +14,8 @@ export type ListFilterOperator =
13
14
  | "greaterThanEqual"
14
15
  | "lessThan"
15
16
  | "lessThanEqual"
16
- | "exists";
17
+ | "exists"
18
+ | "in";
17
19
 
18
20
  export interface ListFilter {
19
21
  field: string;
@@ -58,6 +60,7 @@ export function listColumnFields(collection: SchemaCollection | undefined, title
58
60
 
59
61
  export function filterableFields(fields: readonly SchemaField[]) {
60
62
  return fields.flatMap((field) => {
63
+ if (field.queryRestricted) return [];
61
64
  if (field.type === "group" || field.type === "array" || field.type === "blocks") {
62
65
  return nestedFields(field, "filters");
63
66
  }
@@ -67,8 +70,11 @@ export function filterableFields(fields: readonly SchemaField[]) {
67
70
 
68
71
  export function sortableField(field: SchemaField) {
69
72
  return (
73
+ field.queryRestricted !== true &&
70
74
  field.virtual === undefined &&
71
75
  field.join === undefined &&
76
+ field.type !== "text-list" &&
77
+ field.type !== "number-list" &&
72
78
  field.type !== "array" &&
73
79
  field.type !== "blocks" &&
74
80
  field.type !== "group" &&
@@ -96,6 +102,7 @@ export function defaultListColumns(
96
102
  }
97
103
 
98
104
  export function filterOperatorsFor(field: SchemaField): readonly ListFilterOperator[] {
105
+ if (field.type === "text-list" || field.type === "number-list") return ["in", "exists"];
99
106
  if (field.type === "number" || field.type === "date") return orderedOperators;
100
107
  if (field.type === "text" || field.type === "textarea" || field.type === "email") {
101
108
  return textOperators;
@@ -105,6 +112,7 @@ export function filterOperatorsFor(field: SchemaField): readonly ListFilterOpera
105
112
 
106
113
  export function filterOperatorLabel(operator: ListFilterOperator, i18n: AdminI18n) {
107
114
  const labels: Record<ListFilterOperator, Parameters<AdminI18n["t"]>[0]> = {
115
+ in: "collections:operatorIncludesItem",
108
116
  equals: "collections:operatorEquals",
109
117
  notEquals: "collections:operatorNotEquals",
110
118
  like: "collections:operatorLike",
@@ -165,19 +173,19 @@ export function buildListFilterWhere(
165
173
  const byName = new Map(fields.map((field) => [field.path, field]));
166
174
  return filters.flatMap((filter): Record<string, unknown>[] => {
167
175
  const field = byName.get(filter.field);
168
- if (field === undefined) return [];
176
+ if (field === undefined || !filterOperatorsFor(field).includes(filter.operator)) return [];
169
177
  if (filter.operator === "exists") {
170
178
  return [{ [filter.field]: { exists: filter.value !== "false" } }];
171
179
  }
172
- if (filter.value === "") return [];
180
+ if (filter.value === "" && field.type !== "text-list") return [];
173
181
  let value: string | number | boolean = filter.value;
174
- if (field.type === "number") {
182
+ if (field.type === "number" || field.type === "number-list") {
175
183
  value = Number(filter.value);
176
184
  if (!Number.isFinite(value)) return [];
177
185
  } else if (field.type === "checkbox") {
178
186
  value = filter.value === "true";
179
187
  }
180
- return [{ [filter.field]: { [filter.operator]: value } }];
188
+ return [{ [filter.field]: { [filter.operator]: filter.operator === "in" ? [value] : value } }];
181
189
  });
182
190
  }
183
191
 
@@ -222,16 +230,21 @@ export function visibleColumnNames(encoded: string | null, fallback: readonly st
222
230
  }
223
231
 
224
232
  function nestedFields(field: SchemaField, mode: "columns" | "filters"): SchemaField[] {
233
+ if (mode === "filters" && field.queryRestricted) return [];
225
234
  if (field.type === "blocks") {
226
235
  if (mode === "columns") return [];
227
- return (field.blocks?.types ?? []).flatMap((block) =>
236
+ return (resolveBlockTypes(field.blocks) ?? []).flatMap((block) =>
228
237
  block.fields.flatMap((child) =>
229
- withListLabel(child, `${field.admin.label} > ${block.label}`, mode)
238
+ withListLabel(child, `${field.admin.label} > ${block.labels.singular}`, mode)
230
239
  )
231
240
  );
232
241
  }
233
242
  return (field.nested?.fields ?? []).flatMap((child) =>
234
- withListLabel(child, field.admin.label, mode)
243
+ withListLabel(
244
+ field.queryRestricted ? { ...child, queryRestricted: true } : child,
245
+ field.admin.label,
246
+ mode
247
+ )
235
248
  );
236
249
  }
237
250
 
@@ -254,6 +267,7 @@ function withListLabel(
254
267
 
255
268
  function filterableLeaf(field: SchemaField) {
256
269
  return (
270
+ field.queryRestricted !== true &&
257
271
  field.virtual === undefined &&
258
272
  field.join === undefined &&
259
273
  field.plugin === undefined &&
@@ -1,3 +1,4 @@
1
+ import { connectDocumentLiveValidation } from "@admin/core/forms/live-validation.svelte";
1
2
  import type { DocumentLockEnvelope, SchemaCollection } from "@riducms/protocol";
2
3
  import { RiduError } from "@riducms/sdk";
3
4
  import { tick, untrack } from "svelte";
@@ -85,6 +86,7 @@ export class DocumentController {
85
86
 
86
87
  constructor(readonly options: DocumentControllerOptions) {
87
88
  this.form = new FormController({}, options.runtime.i18n);
89
+ connectDocumentLiveValidation(this.form, options.runtime.client);
88
90
  $effect(() => {
89
91
  const slug = this.options.slug;
90
92
  const documentID = this.options.global ? slug : this.options.documentID;
@@ -146,6 +148,7 @@ export class DocumentController {
146
148
  });
147
149
 
148
150
  $effect(() => () => {
151
+ this.form.disposeBindings();
149
152
  this.#saveRequest?.abort();
150
153
  this.#imageRequest?.abort();
151
154
  if (this.#copyResetTimer !== undefined) window.clearTimeout(this.#copyResetTimer);
@@ -929,7 +932,7 @@ export class DocumentController {
929
932
  this.imageOutcomeUncertain = false;
930
933
  this.form.setAccess(undefined, documentID === undefined ? "create" : "update");
931
934
  if (documentID === undefined) {
932
- this.form.reset(initialFormValues(collection.fields));
935
+ this.form.reset(initialFormValues(collection.fields), collection.fields);
933
936
  this.form.setLocalization(locale);
934
937
  this.versions = [];
935
938
  this.currentDocument = undefined;
@@ -1179,7 +1182,10 @@ export class DocumentController {
1179
1182
 
1180
1183
  #applyDocument(document: AdminDocument, recoverDraft = false) {
1181
1184
  this.currentDocument = document;
1182
- this.form.reset(documentFormValues(this.collection?.fields ?? [], document));
1185
+ this.form.reset(
1186
+ documentFormValues(this.collection?.fields ?? [], document),
1187
+ this.collection?.fields ?? []
1188
+ );
1183
1189
  this.form.setLocalization(this.contentLocale, document._localization?.sources);
1184
1190
  this.currentRevision = typeof document._revision === "number" ? document._revision : 0;
1185
1191
  this.currentStatus = document._status === "draft" ? "draft" : "published";