@riducms/admin 0.2.1 → 0.2.2

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.
@@ -1,4 +1,4 @@
1
- import { cloneSchemaField } from "@riducms/protocol";
1
+ import { FieldBindingLifetime } from "@admin/core/forms/field-binding-lifetime";
2
2
  import type {
3
3
  PluginFieldBinding as Binding,
4
4
  PluginForm,
@@ -9,46 +9,20 @@ import type { FormController } from "@admin/core/forms/form-controller.svelte";
9
9
  import { captureFieldOccurrence } from "@admin/core/forms/field-occurrence";
10
10
  import { writePluginField } from "@admin/core/forms/plugin-field-write";
11
11
  import { cloneFormValue } from "@admin/core/forms/form-schema";
12
- import { fieldAccessPath } from "@admin/fields/nested/scoped-field";
13
- import { evaluateFieldCondition } from "@admin/core/forms/field-condition";
14
12
 
15
13
  /** The form owns values. This lease owns registration, occurrence tracking and child capabilities. */
16
14
  export class PluginFieldBinding implements Binding<unknown> {
17
- #stale = false;
18
- #path: string;
19
- #resolvePath: () => string | undefined;
20
- #ancestors: ReturnType<typeof captureFieldOccurrence>["ancestors"];
21
- #epoch: number;
22
- #version: unknown;
23
- #schemaID: string;
24
- #resource: string;
25
- #locale: string | undefined;
26
- #stopPath: () => void;
27
- #stopObserve: () => void;
28
- #stopLifetime: () => void;
29
- #cleanup = new Set<() => void>();
15
+ #lifetime: FieldBindingLifetime;
30
16
  readonly form: PluginForm;
31
17
  constructor(
32
18
  private controller: FormController,
33
- private getSchema: () => SchemaField,
19
+ getSchema: () => SchemaField,
34
20
  private registration: Pick<RegisteredPluginField, "decodeValue"> &
35
21
  Partial<Pick<RegisteredPluginField, "decodeInput" | "decodeFormValue">>,
36
- private schemaVersion: () => unknown = () => controller.editorEpoch,
22
+ schemaVersion: () => unknown = () => controller.editorEpoch,
37
23
  private owner?: PluginFieldBinding
38
24
  ) {
39
- const schema = getSchema();
40
- this.#path = schema.path;
41
- const occurrence = captureFieldOccurrence(controller, schema.path);
42
- this.#resolvePath = occurrence.resolve;
43
- this.#ancestors = occurrence.ancestors;
44
- this.#epoch = controller.editorEpoch;
45
- this.#schemaID = schema.id;
46
- this.#version = schemaVersion();
47
- this.#resource = JSON.stringify(controller.resource);
48
- this.#locale = controller.contentLocale;
49
- this.#stopPath = controller.register(this.#path);
50
- this.#stopObserve = controller.observe(this.#path.split(".")[0]!, () => this.#resolve());
51
- this.#stopLifetime = controller.registerEditorLifetime(this.destroy);
25
+ this.#lifetime = new FieldBindingLifetime(controller, getSchema, schemaVersion);
52
26
  const binding = this;
53
27
  this.form = {
54
28
  get contentLocale() {
@@ -87,21 +61,20 @@ export class PluginFieldBinding implements Binding<unknown> {
87
61
  };
88
62
  }
89
63
  get stale() {
90
- this.#resolve();
91
- return this.#stale;
64
+ return this.#lifetime.stale;
92
65
  }
93
66
  get schema() {
94
67
  this.assertActive();
95
- const schema = cloneSchemaField(this.getSchema());
68
+ const schema = this.#lifetime.schema;
96
69
  return {
97
70
  ...schema,
98
- path: this.#path,
71
+ path: this.#lifetime.path,
99
72
  admin: { ...schema.admin, readOnly: this.#isReadOnly() },
100
73
  };
101
74
  }
102
75
  get rawValue() {
103
76
  this.assertActive();
104
- return cloneFormValue(this.controller.get(this.#path));
77
+ return cloneFormValue(this.controller.get(this.#lifetime.path));
105
78
  }
106
79
  get value() {
107
80
  const raw = this.rawValue;
@@ -114,16 +87,16 @@ export class PluginFieldBinding implements Binding<unknown> {
114
87
  }
115
88
  get issues() {
116
89
  this.assertActive();
117
- return this.controller.issuesFor(this.#path).map((issue) => ({ ...issue }));
90
+ return this.controller.issuesFor(this.#lifetime.path).map((issue) => ({ ...issue }));
118
91
  }
119
92
  get liveValidation() {
120
93
  if (this.stale) return { status: "idle" as const, retry: () => {} };
121
- const feedback = this.controller.liveValidation.forField(this.#path);
94
+ const feedback = this.controller.liveValidation.forField(this.#lifetime.path);
122
95
  return {
123
96
  status: feedback.status,
124
97
  retry: () => {
125
98
  this.assertActive();
126
- if (!this.readOnly) this.controller.liveValidation.flush(this.#path);
99
+ if (!this.readOnly) this.controller.liveValidation.flush(this.#lifetime.path);
127
100
  },
128
101
  };
129
102
  }
@@ -131,36 +104,8 @@ export class PluginFieldBinding implements Binding<unknown> {
131
104
  get readOnly() {
132
105
  return this.controller.editingBlocked || this.#isReadOnly();
133
106
  }
134
- #isReadOnly() {
135
- if (this.stale || (this.owner !== undefined && this.owner.#isReadOnly())) return true;
136
- if (
137
- this.#ancestors.some(({ schema, resolve }) => {
138
- const path = resolve();
139
- return (
140
- path === undefined ||
141
- schema.admin.readOnly === true ||
142
- schema.admin.hidden === true ||
143
- !this.controller.canRead(path, fieldAccessPath(schema)) ||
144
- !this.controller.canWrite(path, fieldAccessPath(schema)) ||
145
- (schema.admin.condition !== undefined &&
146
- !evaluateFieldCondition(schema.admin.condition, path, (other) =>
147
- this.controller.get(other)
148
- ))
149
- );
150
- })
151
- )
152
- return true;
153
- const field = this.getSchema();
154
- return (
155
- field.admin.readOnly === true ||
156
- field.admin.hidden === true ||
157
- !this.controller.canRead(this.#path, fieldAccessPath(field)) ||
158
- !this.controller.canWrite(this.#path, fieldAccessPath(field)) ||
159
- (field.admin.condition !== undefined &&
160
- !evaluateFieldCondition(field.admin.condition, this.#path, (path) =>
161
- this.controller.get(path)
162
- ))
163
- );
107
+ #isReadOnly(): boolean {
108
+ return this.#lifetime.schemaReadOnly || (this.owner !== undefined && this.owner.#isReadOnly());
164
109
  }
165
110
  set = (value: unknown) => {
166
111
  this.assertEditable();
@@ -173,7 +118,7 @@ export class PluginFieldBinding implements Binding<unknown> {
173
118
  // Decoders and getters are application code and may revoke the binding synchronously.
174
119
  const detached = copyValue(decoded);
175
120
  this.assertEditable();
176
- const schema = { ...this.getSchema(), path: this.#path };
121
+ const schema = this.#lifetime.schema;
177
122
  writePluginField(this.controller, schema, detached);
178
123
  };
179
124
  assertActive = () => {
@@ -188,44 +133,9 @@ export class PluginFieldBinding implements Binding<unknown> {
188
133
  };
189
134
  onDestroy = (cleanup: () => void) => {
190
135
  this.assertActive();
191
- this.#cleanup.add(cleanup);
192
- return () => {
193
- this.#cleanup.delete(cleanup);
194
- };
136
+ return this.#lifetime.onDestroy(cleanup);
195
137
  };
196
- destroy = () => {
197
- if (this.#stale) return;
198
- this.#stale = true;
199
- this.#stopPath();
200
- this.#stopObserve();
201
- this.#stopLifetime();
202
- for (const cleanup of [...this.#cleanup]) cleanup();
203
- this.#cleanup.clear();
204
- };
205
- #resolve() {
206
- if (this.#stale) return;
207
- if (
208
- !this.controller.editorScopeActive ||
209
- this.controller.editorEpoch !== this.#epoch ||
210
- this.schemaVersion() !== this.#version ||
211
- this.getSchema().id !== this.#schemaID ||
212
- this.controller.contentLocale !== this.#locale ||
213
- JSON.stringify(this.controller.resource) !== this.#resource
214
- ) {
215
- this.destroy();
216
- return;
217
- }
218
- const path = this.#resolvePath();
219
- if (path === undefined) {
220
- this.destroy();
221
- return;
222
- }
223
- if (path !== this.#path) {
224
- this.#stopPath();
225
- this.#path = path;
226
- this.#stopPath = this.controller.register(path);
227
- }
228
- }
138
+ destroy = () => this.#lifetime.destroy();
229
139
  }
230
140
 
231
141
  /** Reject non-JSON mutable objects before they can enter the form (including decoder outputs). */
@@ -0,0 +1,18 @@
1
+ import type { SchemaScalarLiteral } from "@riducms/protocol";
2
+
3
+ /** Decode manifest operands without coercing malformed booleans or empty/nonfinite numbers. */
4
+ export function scalarLiteralValue(
5
+ literal: SchemaScalarLiteral
6
+ ): string | number | boolean | undefined {
7
+ switch (literal.type) {
8
+ case "string":
9
+ return literal.value;
10
+ case "number": {
11
+ if (literal.value.trim() === "") return undefined;
12
+ const value = Number(literal.value);
13
+ return Number.isFinite(value) ? value : undefined;
14
+ }
15
+ case "boolean":
16
+ return literal.value === "true" ? true : literal.value === "false" ? false : undefined;
17
+ }
18
+ }
@@ -1,4 +1,4 @@
1
- import { resolvePluginFields, type AdminPlugin, type ResolvedPluginField } from "@riducms/plugin";
1
+ import { type ResolvedPluginField } from "@riducms/plugin";
2
2
  import type { FieldType, SchemaField } from "@riducms/protocol";
3
3
 
4
4
  interface FieldRenderer {
@@ -33,8 +33,10 @@ export class FieldRegistry {
33
33
  return renderer;
34
34
  }
35
35
  }
36
- export function createCoreFieldRegistry(plugins: readonly AdminPlugin[] = []): FieldRegistry {
36
+ export function createCoreFieldRegistry(
37
+ fields: readonly ResolvedPluginField[] = []
38
+ ): FieldRegistry {
37
39
  const registry = new FieldRegistry();
38
- for (const field of resolvePluginFields(plugins)) registry.register(field);
40
+ for (const field of fields) registry.register(field);
39
41
  return registry;
40
42
  }
@@ -1,6 +1,10 @@
1
- import { validateAdminConfig, type AdminConfig } from "@riducms/plugin/admin";
2
1
  import {
3
- resolveAdminExtensions,
2
+ resolveAdminConfig,
3
+ validateAdminManifest,
4
+ type AdminConfig,
5
+ type ResolvedAdminConfig,
6
+ } from "@riducms/plugin/admin";
7
+ import {
4
8
  type AdminDashboardPanel,
5
9
  type AdminLoginComponent,
6
10
  type AdminAccountComponent,
@@ -13,12 +17,10 @@ import {
13
17
  type AdminDocumentAction,
14
18
  type AdminDocumentView,
15
19
  type AdminListCell,
16
- type AdminPlugin,
17
20
  type AdminRoute,
18
21
  } from "@riducms/plugin";
19
22
  import type { AuthSession, OperationCapabilities, SchemaManifest } from "@riducms/protocol";
20
23
  import { RiduError } from "@riducms/sdk";
21
- import type { TranslationLanguage } from "@riducms/translations";
22
24
  import { createContext } from "svelte";
23
25
 
24
26
  import type { AdminClient, AdminDocument } from "@admin/core/api/admin-client";
@@ -166,15 +168,18 @@ export class AdminRuntime {
166
168
  readonly rowLabels: RowLabelRegistry;
167
169
  readonly i18n: AdminI18nController;
168
170
 
171
+ readonly config: ResolvedAdminConfig;
172
+ readonly fields: FieldRegistry;
173
+ readonly editors: ResolvedAdminConfig["editors"];
174
+
169
175
  constructor(
170
176
  readonly client: AdminClient,
171
- readonly plugins: readonly AdminPlugin[] = [],
172
- languages?: readonly TranslationLanguage[],
173
- readonly fields: FieldRegistry = createCoreFieldRegistry(plugins),
174
- readonly editors: NonNullable<AdminConfig["fields"]> = {},
175
- readonly application: AdminConfig = {}
177
+ config: AdminConfig = {}
176
178
  ) {
177
- const extensions = resolveAdminExtensions(plugins, application);
179
+ this.config = resolveAdminConfig(config);
180
+ this.fields = createCoreFieldRegistry(this.config.fields);
181
+ this.editors = this.config.editors;
182
+ const extensions = this.config.extensions;
178
183
  this.pluginRoutes = extensions.routes;
179
184
  this.dashboardPanels = extensions.dashboard;
180
185
  this.loginComponents = extensions.login;
@@ -188,11 +193,11 @@ export class AdminRuntime {
188
193
  this.listCells = extensions.listCells;
189
194
  this.documentActions = extensions.documentActions;
190
195
  this.documentViews = extensions.documentViews;
191
- this.rowLabels = createRowLabelRegistry(extensions.rowLabels, application.rowLabels);
196
+ this.rowLabels = createRowLabelRegistry(extensions.rowLabels, this.config.rowLabels);
192
197
  this.i18n = new AdminI18nController(
193
198
  client,
194
199
  () => this.session,
195
- languages,
200
+ this.config.languages,
196
201
  extensions.messages,
197
202
  extensions.applicationMessages
198
203
  );
@@ -439,10 +444,7 @@ export class AdminRuntime {
439
444
  try {
440
445
  const manifest = await this.client.schema();
441
446
  if (request !== this.#manifestRequest) return;
442
- validateAdminConfig(
443
- { ...this.application, plugins: this.plugins, fields: this.editors },
444
- manifest
445
- );
447
+ validateAdminManifest(this.config, manifest);
446
448
  this.manifest = manifest;
447
449
  this.manifestRevision += 1;
448
450
  this.#configureContentLocale();
@@ -495,10 +497,7 @@ export class AdminRuntime {
495
497
  try {
496
498
  const manifest = await this.client.schema();
497
499
  if (request !== this.#manifestRequest) return;
498
- validateAdminConfig(
499
- { ...this.application, plugins: this.plugins, fields: this.editors },
500
- manifest
501
- );
500
+ validateAdminManifest(this.config, manifest);
502
501
  this.manifest = manifest;
503
502
  this.manifestRevision += 1;
504
503
  this.#configureContentLocale();
@@ -5,6 +5,7 @@
5
5
  import { Button } from "@admin/components/ui/button";
6
6
  import { focusFieldIssue } from "@admin/core/forms/field-issue-focus";
7
7
  import FieldLayout from "@admin/fields/field-layout.svelte";
8
+ import BlockHeader from "@admin/fields/nested/block-header.svelte";
8
9
  import { onDestroy } from "svelte";
9
10
 
10
11
  let {
@@ -52,6 +53,16 @@
52
53
  <div class="border-b border-border px-6 py-4">
53
54
  <SheetTitle>{options.title ?? i18n.t("fields:embeddedEditTitle")}</SheetTitle>
54
55
  <SheetDescription>{i18n.t("fields:embeddedEditDescription")}</SheetDescription>
56
+ {#if !draft.stale && session.block.admin?.nameField !== undefined}
57
+ <div class="mt-3">
58
+ <BlockHeader
59
+ block={session.block}
60
+ path={draft.id}
61
+ instance={draft.id}
62
+ form={session.form}
63
+ />
64
+ </div>
65
+ {/if}
55
66
  </div>
56
67
  <div class="min-h-0 flex-1 overflow-y-auto px-6 py-5" data-schema-draft={draft.id}>
57
68
  {#if draft.stale}
@@ -64,7 +75,10 @@
64
75
  {#each draft.issues as issue}<p>{issue.message}</p>{/each}
65
76
  </div>
66
77
  {/if}
67
- {#if !draft.stale}<FieldLayout fields={session.fields} form={session.form} />{/if}
78
+ {#if !draft.stale}<FieldLayout
79
+ fields={session.fields.filter((field) => field.name !== session.block.admin?.nameField)}
80
+ form={session.form}
81
+ />{/if}
68
82
  </div>
69
83
  <div class="flex justify-end gap-2 border-t border-border px-6 py-4">
70
84
  <Button variant="outline" onclick={cancel}>{i18n.t("general:cancel")}</Button>
@@ -5,6 +5,7 @@
5
5
  import type { EmbeddedOccurrence } from "@admin/core/forms/embedded-fields";
6
6
  import { fieldAccessPath, scopeRepeatedRowField } from "@admin/fields/nested/scoped-field";
7
7
  import FieldLayout from "@admin/fields/field-layout.svelte";
8
+ import BlockHeader from "@admin/fields/nested/block-header.svelte";
8
9
 
9
10
  let {
10
11
  field,
@@ -40,5 +41,17 @@
40
41
  The embedded field occurrence is no longer available. Close this editor and reopen it.
41
42
  </p>
42
43
  {:else}
43
- <FieldLayout fields={children} {form} />
44
+ <BlockHeader
45
+ block={occurrence.block}
46
+ path={occurrence.path}
47
+ instance={`${field.id}-${scope.treeKey}-${scope.identity}`}
48
+ {form}
49
+ readOnly={scope.readOnly === true ||
50
+ field.admin.readOnly === true ||
51
+ !form.canWrite(field.path, fieldAccessPath(field))}
52
+ />
53
+ <FieldLayout
54
+ fields={children.filter((child) => child.name !== occurrence.block.admin?.nameField)}
55
+ {form}
56
+ />
44
57
  {/if}
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import type { SchemaBlockType } from "@riducms/protocol";
3
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
4
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
5
+ import { scopeRepeatedRowField } from "@admin/fields/nested/scoped-field";
6
+ import BlockNameInput from "@admin/fields/nested/block-name-input.svelte";
7
+
8
+ let {
9
+ block,
10
+ path,
11
+ instance,
12
+ form,
13
+ readOnly = false,
14
+ disabled = false,
15
+ onChange,
16
+ }: {
17
+ block: SchemaBlockType;
18
+ path: string;
19
+ instance: string;
20
+ form: FormController;
21
+ readOnly?: boolean;
22
+ disabled?: boolean;
23
+ onChange?: (change: { field: string; value: string }) => void;
24
+ } = $props();
25
+ const runtime = getAdminRuntime();
26
+ const child = $derived(block.fields.find((field) => field.name === block.admin?.nameField));
27
+ const schema = $derived(
28
+ child === undefined ? undefined : scopeRepeatedRowField(child, path, instance)
29
+ );
30
+ </script>
31
+
32
+ {#if schema !== undefined}
33
+ {#key form}
34
+ {#key `${form.editorEpoch}:${runtime.manifestRevision}:${schema.id}`}
35
+ <BlockNameInput {schema} {block} {form} {readOnly} {disabled} {onChange} />
36
+ {/key}
37
+ {/key}
38
+ {/if}
@@ -0,0 +1,28 @@
1
+ import type { SchemaBlockType, SchemaField } from "@riducms/protocol";
2
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
3
+ import { evaluateFieldCondition } from "@admin/core/forms/field-condition";
4
+ import { fieldAccessPath, scopeRepeatedRowField } from "@admin/fields/nested/scoped-field";
5
+
6
+ /** Header summaries observe the same child visibility policy as the content form. */
7
+ export function visibleBlockChild(form: FormController, field: SchemaField) {
8
+ return (
9
+ field.admin.hidden !== true &&
10
+ form.canRead(field.path, fieldAccessPath(field)) &&
11
+ (field.admin.condition === undefined ||
12
+ evaluateFieldCondition(field.admin.condition, field.path, (path) => form.get(path)))
13
+ );
14
+ }
15
+
16
+ export function blockHeaderValue(
17
+ form: FormController,
18
+ block: SchemaBlockType,
19
+ path: string,
20
+ name: string | undefined
21
+ ) {
22
+ const child = block.fields.find((field) => field.name === name);
23
+ if (child === undefined) return "";
24
+ const scoped = scopeRepeatedRowField(child, path, "header-summary");
25
+ if (!visibleBlockChild(form, scoped)) return "";
26
+ const value = form.get(scoped.path);
27
+ return value === undefined || value === null || String(value).trim() === "" ? "" : String(value);
28
+ }
@@ -0,0 +1,126 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from "svelte";
3
+ import type { SchemaBlockType, SchemaField } from "@riducms/protocol";
4
+ import { Input } from "@admin/components/ui/input";
5
+ import FieldMessages from "@admin/fields/field-messages.svelte";
6
+ import LiveValidationFeedback from "@admin/fields/live-validation-feedback.svelte";
7
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
8
+ import { FieldEditorBinding } from "@admin/core/forms/field-editor-binding";
9
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
10
+ import { localizeSchemaField } from "@admin/core/i18n/localized-schema";
11
+ import { blockHeaderValue } from "@admin/fields/nested/block-header";
12
+ import {
13
+ contentLocaleLabel,
14
+ withContentLocaleLabel,
15
+ } from "@admin/fields/localized-field-presentation";
16
+
17
+ let {
18
+ schema,
19
+ block,
20
+ form,
21
+ readOnly = false,
22
+ disabled = false,
23
+ onChange,
24
+ }: {
25
+ schema: SchemaField;
26
+ block: SchemaBlockType;
27
+ form: FormController;
28
+ readOnly?: boolean;
29
+ disabled?: boolean;
30
+ onChange?: (change: { field: string; value: string }) => void;
31
+ } = $props();
32
+ const runtime = getAdminRuntime();
33
+ const presented = $derived(
34
+ withContentLocaleLabel(
35
+ localizeSchemaField(
36
+ {
37
+ ...schema,
38
+ admin: { ...schema.admin, readOnly: schema.admin.readOnly || readOnly || disabled },
39
+ },
40
+ runtime.i18n
41
+ ),
42
+ contentLocaleLabel(runtime.contentLocales, form.contentLocale)
43
+ )
44
+ );
45
+ // The keyed host owns one occurrence capability, including delayed callbacks.
46
+ // svelte-ignore state_referenced_locally
47
+ const binding = new FieldEditorBinding(
48
+ form,
49
+ () => presented,
50
+ "text",
51
+ () => runtime.manifestRevision,
52
+ (value) => {
53
+ const name = binding.schema.name;
54
+ if (onChange !== undefined) onChange({ field: name, value: value ?? "" });
55
+ else form.set(binding.schema.path, value);
56
+ }
57
+ );
58
+ onDestroy(binding.destroy);
59
+ const current = $derived(binding.schema);
60
+ const placeholder = $derived(
61
+ blockHeaderValue(
62
+ form,
63
+ block,
64
+ current.path.slice(0, current.path.lastIndexOf(".")),
65
+ block.admin?.rowLabel
66
+ ) ||
67
+ runtime.i18n.t("fields:untitled", {
68
+ label: runtime.i18n.text(block.labels.singular, block.labels.singularTranslations),
69
+ })
70
+ );
71
+ const inherited = $derived(
72
+ form.isInherited(current.path) ? form.localizationSource(current.path) : undefined
73
+ );
74
+ function keydown(event: KeyboardEvent) {
75
+ if (event.key === "Enter" && !event.isComposing) event.preventDefault();
76
+ }
77
+ function focusout() {
78
+ // Removing a keyed schema host can dispatch focusout after its derived
79
+ // values are destroyed. The binding lease is the synchronous lifetime guard.
80
+ if (!binding.stale) form.liveValidation.flush(binding.schema.path);
81
+ }
82
+ </script>
83
+
84
+ {#if binding.visible}
85
+ <div class="min-w-0 flex-1" data-field-path={current.path} onfocusout={focusout}>
86
+ <FieldMessages
87
+ controlID={current.id}
88
+ description={current.admin.description}
89
+ issues={binding.issues}
90
+ >
91
+ <label class="sr-only" for={current.id}>{current.admin.label}</label>
92
+ <Input
93
+ {...binding.inputProps}
94
+ value={binding.value ?? ""}
95
+ {placeholder}
96
+ readonly={binding.readOnly}
97
+ {disabled}
98
+ minlength={current.text?.minLength}
99
+ maxlength={current.text?.maxLength}
100
+ class="h-8 min-w-0"
101
+ oninput={(event) => binding.set(event.currentTarget.value)}
102
+ onkeydown={keydown}
103
+ />
104
+ </FieldMessages>
105
+ <LiveValidationFeedback
106
+ feedback={binding.liveValidation}
107
+ i18n={runtime.i18n}
108
+ path={current.path}
109
+ />
110
+ {#if inherited !== undefined}<p
111
+ class="mt-1 text-xs text-foreground-faint"
112
+ data-localization-source={inherited}
113
+ >
114
+ {runtime.i18n.t("documents:inheritedFrom", {
115
+ locale: contentLocaleLabel(runtime.contentLocales, inherited) ?? inherited,
116
+ })}
117
+ </p>{:else if current.localized && form.contentLocale !== undefined && binding.value === undefined}
118
+ <p class="mt-1 text-xs text-foreground-faint" data-localization-missing={form.contentLocale}>
119
+ {runtime.i18n.t("documents:missingTranslation", {
120
+ locale:
121
+ contentLocaleLabel(runtime.contentLocales, form.contentLocale) ?? form.contentLocale,
122
+ })}
123
+ </p>
124
+ {/if}
125
+ </div>
126
+ {/if}
@@ -43,10 +43,15 @@
43
43
  } from "@admin/fields/field-clipboard";
44
44
  import { scopeRepeatedRowField } from "@admin/fields/nested/scoped-field";
45
45
  import SortableRow from "@admin/fields/nested/sortable-row.svelte";
46
+ import BlockHeader from "@admin/fields/nested/block-header.svelte";
47
+ import { blockHeaderValue, visibleBlockChild } from "@admin/fields/nested/block-header";
46
48
 
47
49
  let { field, form }: { field: SchemaField; form: FormController } = $props();
48
50
  const runtime = getAdminRuntime();
49
51
  const customRowLabel = $derived(runtime.rowLabels.resolve(field));
52
+ const blockTypes = $derived(
53
+ new Map(resolveBlockTypes(field.blocks).map((block) => [block.slug, block]))
54
+ );
50
55
  const rows = $derived((form.get(field.path) as Record<string, unknown>[] | undefined) ?? []);
51
56
  const issues = $derived(form.issuesFor(field.path).filter((issue) => issue.path === field.path));
52
57
  const controlARIA = $derived(
@@ -226,6 +231,15 @@
226
231
  }
227
232
 
228
233
  function rowLabel(row: Record<string, unknown>, index: number) {
234
+ const block = blockTypes.get(String(row.blockType));
235
+ if (field.type === "blocks" && block?.admin?.nameField !== undefined) {
236
+ const path = `${field.path}.${index}`;
237
+ return (
238
+ blockHeaderValue(form, block, path, block.admin.nameField) ||
239
+ blockHeaderValue(form, block, path, block.admin.rowLabel) ||
240
+ runtime.i18n.t("fields:untitled", { label: block.labels.singular })
241
+ );
242
+ }
229
243
  const key =
230
244
  field.type === "blocks"
231
245
  ? resolveBlockTypes(field.blocks).find((block) => block.slug === row.blockType)?.admin
@@ -297,7 +311,22 @@
297
311
 
298
312
  function fieldsForRow(row: Record<string, unknown>, index: number) {
299
313
  const rowKey = String(row._key);
300
- return fieldsFor(row).map((child) => rowField(child, index, rowKey));
314
+ const nameField = blockTypes.get(String(row.blockType))?.admin?.nameField;
315
+ return fieldsFor(row)
316
+ .filter((child) => child.name !== nameField)
317
+ .map((child) => rowField(child, index, rowKey));
318
+ }
319
+
320
+ function rowLabelSnapshot(row: Record<string, unknown>, index: number) {
321
+ const block = blockTypes.get(String(row.blockType));
322
+ if (block?.admin?.nameField === undefined) return immutableRowLabelSnapshot(row);
323
+ const visible = { ...row };
324
+ for (const name of [block.admin.nameField, block.admin.rowLabel]) {
325
+ const child = block.fields.find((field) => field.name === name);
326
+ if (child !== undefined && !visibleBlockChild(form, rowField(child, index, String(row._key))))
327
+ delete visible[child.name];
328
+ }
329
+ return immutableRowLabelSnapshot(visible);
301
330
  }
302
331
 
303
332
  function reorder(event: Parameters<DragDropEvents["dragend"]>[0]) {
@@ -416,7 +445,8 @@
416
445
  {const rowKey = String(row._key)}
417
446
  {const rowCollapsed = $derived(collapsed.has(rowKey))}
418
447
  {const rowIssues = $derived(form.issuesFor(`${field.path}.${index}`))}
419
- {const rowSnapshot = $derived(immutableRowLabelSnapshot(row))}
448
+ {const rowSnapshot = $derived(rowLabelSnapshot(row, index))}
449
+ {const block = $derived(blockTypes.get(String(row.blockType)))}
420
450
  <SortableRow id={String(row._key)} {index} disabled={editingBlocked}>
421
451
  {#snippet children(sortable)}
422
452
  <div class="flex min-h-10 items-center justify-between gap-2 bg-control px-3 py-2">
@@ -447,10 +477,19 @@
447
477
  >
448
478
  {blockLabel(row)}
449
479
  </span>{/if}
450
- <div class="min-w-0 flex-1 truncate">
451
- {#if customRowLabel === undefined}
480
+ <div class="min-w-0 flex-1">
481
+ {#if block?.admin?.nameField !== undefined}
482
+ <BlockHeader
483
+ {block}
484
+ path={`${field.path}.${index}`}
485
+ instance={rowKey}
486
+ {form}
487
+ readOnly={editingBlocked}
488
+ />
489
+ {:else if customRowLabel === undefined}
452
490
  {visibleRowLabel(row, index)}
453
- {:else}
491
+ {/if}
492
+ {#if customRowLabel !== undefined}
454
493
  <customRowLabel.component
455
494
  {field}
456
495
  row={rowSnapshot}