@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.
- package/package.json +7 -7
- package/src/app.svelte +1 -10
- package/src/core/api/admin-client.ts +2 -0
- package/src/core/forms/embedded-schema-draft.svelte.ts +4 -2
- package/src/core/forms/field-binding-lifetime.ts +155 -0
- package/src/core/forms/field-condition.ts +6 -11
- package/src/core/forms/field-editor-binding.ts +27 -105
- package/src/core/forms/field-occurrence.ts +6 -23
- package/src/core/forms/form-controller.svelte.ts +68 -0
- package/src/core/forms/plugin-field-authoring.ts +1 -0
- package/src/core/forms/plugin-field-binding.ts +17 -107
- package/src/core/forms/scalar-literal.ts +18 -0
- package/src/core/plugins/field-registry.ts +5 -3
- package/src/core/runtime/admin-runtime.svelte.ts +19 -20
- package/src/fields/embedded-schema-draft-editor.svelte +15 -1
- package/src/fields/embedded-schema-fields.svelte +14 -1
- package/src/fields/nested/block-header.svelte +38 -0
- package/src/fields/nested/block-header.ts +28 -0
- package/src/fields/nested/block-name-input.svelte +126 -0
- package/src/fields/nested/nested-field.svelte +44 -5
- package/src/fields/plugin-field.svelte +39 -3
- package/src/fields/relationship/relationship-query.ts +2 -17
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
#
|
|
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
|
-
|
|
19
|
+
getSchema: () => SchemaField,
|
|
34
20
|
private registration: Pick<RegisteredPluginField, "decodeValue"> &
|
|
35
21
|
Partial<Pick<RegisteredPluginField, "decodeInput" | "decodeFormValue">>,
|
|
36
|
-
|
|
22
|
+
schemaVersion: () => unknown = () => controller.editorEpoch,
|
|
37
23
|
private owner?: PluginFieldBinding
|
|
38
24
|
) {
|
|
39
|
-
|
|
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.#
|
|
91
|
-
return this.#stale;
|
|
64
|
+
return this.#lifetime.stale;
|
|
92
65
|
}
|
|
93
66
|
get schema() {
|
|
94
67
|
this.assertActive();
|
|
95
|
-
const schema =
|
|
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
|
-
|
|
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 =
|
|
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.#
|
|
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 {
|
|
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(
|
|
36
|
+
export function createCoreFieldRegistry(
|
|
37
|
+
fields: readonly ResolvedPluginField[] = []
|
|
38
|
+
): FieldRegistry {
|
|
37
39
|
const registry = new FieldRegistry();
|
|
38
|
-
for (const field of
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
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(
|
|
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
|
|
451
|
-
{#if
|
|
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
|
-
{
|
|
491
|
+
{/if}
|
|
492
|
+
{#if customRowLabel !== undefined}
|
|
454
493
|
<customRowLabel.component
|
|
455
494
|
{field}
|
|
456
495
|
row={rowSnapshot}
|