@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
package/package.json
CHANGED
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"@hvniel/svelte-router": "^0.0.6",
|
|
10
10
|
"@iconify/json": "^2.2.509",
|
|
11
11
|
"@internationalized/date": "^3.12.3",
|
|
12
|
-
"@riducms/plugin": "0.2.
|
|
13
|
-
"@riducms/protocol": "0.2.
|
|
14
|
-
"@riducms/sdk": "0.2.
|
|
15
|
-
"@riducms/translations": "0.2.
|
|
16
|
-
"@riducms/ui": "0.2.
|
|
12
|
+
"@riducms/plugin": "0.2.2",
|
|
13
|
+
"@riducms/protocol": "0.2.2",
|
|
14
|
+
"@riducms/sdk": "0.2.2",
|
|
15
|
+
"@riducms/translations": "0.2.2",
|
|
16
|
+
"@riducms/ui": "0.2.2",
|
|
17
17
|
"@types/prismjs": "^1.26.5",
|
|
18
18
|
"bits-ui": "^2.18.1",
|
|
19
19
|
"prismjs": "^1.30.0",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"description": "Framework-owned Svelte 5 authoring application embedded by Ridu servers.",
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@hvniel/lexical-svelte": "^0.1.1",
|
|
25
|
-
"@riducms/build": "0.2.
|
|
25
|
+
"@riducms/build": "0.2.2",
|
|
26
26
|
"svelte": "^5.56.8"
|
|
27
27
|
},
|
|
28
28
|
"exports": {
|
|
@@ -60,5 +60,5 @@
|
|
|
60
60
|
"test:unit": "bun test tests"
|
|
61
61
|
},
|
|
62
62
|
"type": "module",
|
|
63
|
-
"version": "0.2.
|
|
63
|
+
"version": "0.2.2"
|
|
64
64
|
}
|
package/src/app.svelte
CHANGED
|
@@ -25,16 +25,7 @@
|
|
|
25
25
|
let { clientFactory, adminBasePath = "/admin", adminConfig = {} }: Props = $props();
|
|
26
26
|
// The runtime owns the client and plugin registry selected when this admin instance is mounted.
|
|
27
27
|
// svelte-ignore state_referenced_locally
|
|
28
|
-
const runtime = setAdminRuntime(
|
|
29
|
-
new AdminRuntime(
|
|
30
|
-
clientFactory(),
|
|
31
|
-
adminConfig.plugins,
|
|
32
|
-
adminConfig.languages,
|
|
33
|
-
undefined,
|
|
34
|
-
adminConfig.fields,
|
|
35
|
-
adminConfig
|
|
36
|
-
)
|
|
37
|
-
);
|
|
28
|
+
const runtime = setAdminRuntime(new AdminRuntime(clientFactory(), adminConfig));
|
|
38
29
|
setAdminI18n(runtime.i18n);
|
|
39
30
|
const notifications = setNotificationCenter(new NotificationCenter());
|
|
40
31
|
const development = import.meta.hot !== undefined;
|
|
@@ -13,6 +13,7 @@ type AdminCollection = CollectionContract & {
|
|
|
13
13
|
auth: true;
|
|
14
14
|
upload: true;
|
|
15
15
|
versions: true;
|
|
16
|
+
drafts: true;
|
|
16
17
|
output: AdminDocument;
|
|
17
18
|
create: Record<string, unknown>;
|
|
18
19
|
update: Record<string, unknown>;
|
|
@@ -24,6 +25,7 @@ type AdminCollection = CollectionContract & {
|
|
|
24
25
|
|
|
25
26
|
type AdminGlobal = {
|
|
26
27
|
versions: true;
|
|
28
|
+
drafts: true;
|
|
27
29
|
output: AdminDocument;
|
|
28
30
|
update: Record<string, unknown>;
|
|
29
31
|
select: Record<string, boolean>;
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
EmbeddedSchemaFormScope,
|
|
6
6
|
EmbeddedSchemaVariantScope,
|
|
7
7
|
} from "@riducms/plugin";
|
|
8
|
-
import type { SchemaField } from "@riducms/protocol";
|
|
8
|
+
import type { SchemaBlockType, SchemaField } from "@riducms/protocol";
|
|
9
9
|
import { FormController } from "@admin/core/forms/form-controller.svelte";
|
|
10
10
|
import { embeddedOccurrences } from "@admin/core/forms/embedded-fields";
|
|
11
11
|
import { cloneFormValue, initialFormValues } from "@admin/core/forms/form-schema";
|
|
@@ -19,6 +19,8 @@ export interface HostedSchemaDraft {
|
|
|
19
19
|
draft: EmbeddedSchemaDraft;
|
|
20
20
|
form: FormController;
|
|
21
21
|
fields: SchemaField[];
|
|
22
|
+
block: SchemaBlockType;
|
|
23
|
+
treeKey: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export function embeddedVariant(field: SchemaField, scope: EmbeddedSchemaVariantScope) {
|
|
@@ -331,5 +333,5 @@ export function createEmbeddedSchemaDraft(
|
|
|
331
333
|
form.disposeBindings();
|
|
332
334
|
},
|
|
333
335
|
};
|
|
334
|
-
return { draft, form, fields };
|
|
336
|
+
return { draft, form, fields, block, treeKey: scope.treeKey };
|
|
335
337
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { cloneSchemaField, type SchemaField } from "@riducms/protocol";
|
|
2
|
+
import type { FormController } from "@admin/core/forms/form-controller.svelte";
|
|
3
|
+
import { captureFieldOccurrence, type FieldOccurrence } from "@admin/core/forms/field-occurrence";
|
|
4
|
+
import { evaluateFieldCondition } from "@admin/core/forms/field-condition";
|
|
5
|
+
import { fieldAccessPath } from "@admin/fields/nested/scoped-field";
|
|
6
|
+
|
|
7
|
+
/** Private owner shared by local and paired adapters. Revocation is synchronous and permanent. */
|
|
8
|
+
export class FieldBindingLifetime {
|
|
9
|
+
#revoked = false;
|
|
10
|
+
#source: SchemaField;
|
|
11
|
+
#snapshot: SchemaField;
|
|
12
|
+
#path: string;
|
|
13
|
+
#occurrence: FieldOccurrence;
|
|
14
|
+
#epoch: number;
|
|
15
|
+
#version: unknown;
|
|
16
|
+
#resource: string;
|
|
17
|
+
#locale: string | undefined;
|
|
18
|
+
#stopPath: () => void;
|
|
19
|
+
#stopObserve: () => void;
|
|
20
|
+
#stopLifetime: () => void;
|
|
21
|
+
#cleanup = new Set<() => void>();
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private form: FormController,
|
|
25
|
+
private getSchema: () => SchemaField,
|
|
26
|
+
private schemaVersion: () => unknown = () => form.editorEpoch
|
|
27
|
+
) {
|
|
28
|
+
this.#source = getSchema();
|
|
29
|
+
this.#snapshot = cloneSchemaField(this.#source);
|
|
30
|
+
this.#path = this.#source.path;
|
|
31
|
+
this.#occurrence = captureFieldOccurrence(form, this.#path);
|
|
32
|
+
this.#epoch = form.editorEpoch;
|
|
33
|
+
this.#version = schemaVersion();
|
|
34
|
+
this.#resource = JSON.stringify(form.resource);
|
|
35
|
+
this.#locale = form.contentLocale;
|
|
36
|
+
this.#stopPath = form.register(this.#path);
|
|
37
|
+
this.#stopObserve = form.observe(this.#path.split(".")[0]!, () => this.#resolve());
|
|
38
|
+
this.#stopLifetime = form.registerEditorLifetime(this.destroy);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get stale() {
|
|
42
|
+
this.#resolve();
|
|
43
|
+
return this.#revoked;
|
|
44
|
+
}
|
|
45
|
+
get path() {
|
|
46
|
+
this.#resolve();
|
|
47
|
+
return this.#path;
|
|
48
|
+
}
|
|
49
|
+
/** A revoked local host can render cached metadata once before its subtree unmounts. */
|
|
50
|
+
get schema(): SchemaField {
|
|
51
|
+
this.#resolve();
|
|
52
|
+
return { ...cloneSchemaField(this.#revoked ? this.#snapshot : this.#source), path: this.#path };
|
|
53
|
+
}
|
|
54
|
+
get visible() {
|
|
55
|
+
if (this.stale) return false;
|
|
56
|
+
return this.#selections().every(({ schema, resolve }) => {
|
|
57
|
+
const path = resolve();
|
|
58
|
+
return path !== undefined && this.#visible(schema, path);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Permission/schema restrictions exclude the temporary pending-save lock. */
|
|
62
|
+
get schemaReadOnly() {
|
|
63
|
+
if (this.stale) return true;
|
|
64
|
+
return this.#selections().some(({ schema, resolve }) => {
|
|
65
|
+
const path = resolve();
|
|
66
|
+
return (
|
|
67
|
+
path === undefined ||
|
|
68
|
+
schema.admin.readOnly === true ||
|
|
69
|
+
!this.#visible(schema, path) ||
|
|
70
|
+
!this.form.canWrite(path, fieldAccessPath(schema))
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
get readOnly() {
|
|
75
|
+
return this.schemaReadOnly || this.form.editingBlocked;
|
|
76
|
+
}
|
|
77
|
+
#selections() {
|
|
78
|
+
return [...this.#occurrence.ancestors, { schema: this.#source, resolve: () => this.#path }];
|
|
79
|
+
}
|
|
80
|
+
#visible(schema: SchemaField, path: string) {
|
|
81
|
+
return (
|
|
82
|
+
schema.admin.hidden !== true &&
|
|
83
|
+
this.form.canRead(path, fieldAccessPath(schema)) &&
|
|
84
|
+
(schema.admin.condition === undefined ||
|
|
85
|
+
evaluateFieldCondition(schema.admin.condition, path, (other) => this.form.get(other)))
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
onDestroy(cleanup: () => void) {
|
|
89
|
+
if (this.stale) {
|
|
90
|
+
cleanup();
|
|
91
|
+
return () => {};
|
|
92
|
+
}
|
|
93
|
+
this.#cleanup.add(cleanup);
|
|
94
|
+
return () => {
|
|
95
|
+
this.#cleanup.delete(cleanup);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
destroy = () => {
|
|
99
|
+
if (this.#revoked) return;
|
|
100
|
+
this.#revoked = true;
|
|
101
|
+
// Never call component-owned getters during or after revocation.
|
|
102
|
+
this.#snapshot = cloneSchemaField(this.#source);
|
|
103
|
+
this.#stopPath();
|
|
104
|
+
this.#stopObserve();
|
|
105
|
+
this.#stopLifetime();
|
|
106
|
+
const callbacks = [...this.#cleanup];
|
|
107
|
+
this.#cleanup.clear();
|
|
108
|
+
// Dispose every child even if one cleanup fails.
|
|
109
|
+
const failures: unknown[] = [];
|
|
110
|
+
for (const cleanup of callbacks) {
|
|
111
|
+
try {
|
|
112
|
+
cleanup();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
failures.push(error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (failures.length) throw new AggregateError(failures, "Field binding cleanup failed.");
|
|
118
|
+
};
|
|
119
|
+
#resolve() {
|
|
120
|
+
if (this.#revoked) return;
|
|
121
|
+
if (
|
|
122
|
+
this.form.editorEpoch !== this.#epoch ||
|
|
123
|
+
this.form.contentLocale !== this.#locale ||
|
|
124
|
+
JSON.stringify(this.form.resource) !== this.#resource ||
|
|
125
|
+
!this.form.editorScopeActive
|
|
126
|
+
) {
|
|
127
|
+
this.destroy();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (this.#revoked) return;
|
|
131
|
+
const version = this.schemaVersion();
|
|
132
|
+
if (this.#revoked) return;
|
|
133
|
+
if (version !== this.#version) {
|
|
134
|
+
this.destroy();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const schema = this.getSchema();
|
|
138
|
+
if (this.#revoked) return;
|
|
139
|
+
if (schema.id !== this.#source.id) {
|
|
140
|
+
this.destroy();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.#source = schema;
|
|
144
|
+
const path = this.#occurrence.resolve();
|
|
145
|
+
if (path === undefined) {
|
|
146
|
+
this.destroy();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (path !== this.#path) {
|
|
150
|
+
this.#stopPath();
|
|
151
|
+
this.#path = path;
|
|
152
|
+
this.#stopPath = this.form.register(path);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { scalarLiteralValue } from "@admin/core/forms/scalar-literal";
|
|
1
2
|
import type {
|
|
2
3
|
SchemaFieldCondition,
|
|
3
4
|
SchemaFieldConditionPredicate,
|
|
4
|
-
|
|
5
|
+
SchemaScalarLiteral,
|
|
5
6
|
} from "@riducms/protocol";
|
|
6
7
|
|
|
7
8
|
export type FieldConditionValueReader = (path: string) => unknown;
|
|
@@ -40,7 +41,7 @@ function evaluatePredicate(
|
|
|
40
41
|
read: FieldConditionValueReader
|
|
41
42
|
) {
|
|
42
43
|
const actual = read(fieldConditionValuePath(predicate.scope, predicate.path, fieldPath));
|
|
43
|
-
const matches = (expected:
|
|
44
|
+
const matches = (expected: SchemaScalarLiteral) => scalarMatches(actual, expected);
|
|
44
45
|
switch (predicate.operator) {
|
|
45
46
|
case "equals":
|
|
46
47
|
return predicate.values[0] !== undefined && matches(predicate.values[0]);
|
|
@@ -51,13 +52,7 @@ function evaluatePredicate(
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function scalarMatches(actual: unknown, expected:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return typeof actual === "string" && actual === expected.value;
|
|
58
|
-
case "number":
|
|
59
|
-
return typeof actual === "number" && actual === Number(expected.value);
|
|
60
|
-
case "boolean":
|
|
61
|
-
return typeof actual === "boolean" && actual === (expected.value === "true");
|
|
62
|
-
}
|
|
55
|
+
function scalarMatches(actual: unknown, expected: SchemaScalarLiteral) {
|
|
56
|
+
const value = scalarLiteralValue(expected);
|
|
57
|
+
return value !== undefined && actual === value;
|
|
63
58
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FieldBindingLifetime } from "@admin/core/forms/field-binding-lifetime";
|
|
2
2
|
import type {
|
|
3
3
|
FieldBinding,
|
|
4
4
|
FieldEditorProps,
|
|
@@ -8,55 +8,32 @@ import type {
|
|
|
8
8
|
import type { SchemaField } from "@riducms/protocol";
|
|
9
9
|
import { fieldControlARIA } from "@riducms/ui";
|
|
10
10
|
import type { FormController } from "@admin/core/forms/form-controller.svelte";
|
|
11
|
-
import { fieldAccessPath } from "@admin/fields/nested/scoped-field";
|
|
12
11
|
import { cloneFormValue } from "@admin/core/forms/form-schema";
|
|
13
|
-
import { evaluateFieldCondition } from "@admin/core/forms/field-condition";
|
|
14
|
-
import { captureFieldOccurrence, type FieldOccurrence } from "@admin/core/forms/field-occurrence";
|
|
15
12
|
|
|
16
13
|
/** The host owns this capability until unmount or an invalidating form transition. */
|
|
17
14
|
export class FieldEditorBinding<Type extends FieldEditorType> implements FieldBinding<Type> {
|
|
18
15
|
#form: FormController;
|
|
19
|
-
#
|
|
20
|
-
#occurrence: FieldOccurrence;
|
|
21
|
-
#path: string;
|
|
22
|
-
#stale = false;
|
|
23
|
-
#stopPath: () => void;
|
|
24
|
-
#stopObserve: () => void;
|
|
25
|
-
#stopLifetime: () => void;
|
|
26
|
-
#epoch: number;
|
|
27
|
-
#resource: string;
|
|
28
|
-
#locale: string | undefined;
|
|
16
|
+
#lifetime: FieldBindingLifetime;
|
|
29
17
|
#type: Type;
|
|
30
|
-
#
|
|
31
|
-
#schemaVersion: () => unknown;
|
|
32
|
-
#initialSchemaVersion: unknown;
|
|
18
|
+
#apply: ((value: FieldEditorValue<Type> | null) => void) | undefined;
|
|
33
19
|
readonly form: FieldEditorProps["form"];
|
|
34
20
|
|
|
35
21
|
constructor(
|
|
36
22
|
form: FormController,
|
|
37
23
|
schema: () => SchemaField,
|
|
38
24
|
type: Type,
|
|
39
|
-
schemaVersion: () => unknown = () => form.editorEpoch
|
|
25
|
+
schemaVersion: () => unknown = () => form.editorEpoch,
|
|
26
|
+
apply?: (value: FieldEditorValue<Type> | null) => void
|
|
40
27
|
) {
|
|
41
28
|
this.#form = form;
|
|
42
|
-
this.#
|
|
43
|
-
this.#schemaVersion = schemaVersion;
|
|
44
|
-
this.#initialSchemaVersion = schemaVersion();
|
|
45
|
-
this.#schema = schema;
|
|
29
|
+
this.#apply = apply;
|
|
46
30
|
this.#type = type;
|
|
47
|
-
this.#
|
|
48
|
-
this.#resource = JSON.stringify(form.resource);
|
|
49
|
-
this.#locale = form.contentLocale;
|
|
50
|
-
this.#path = schema().path;
|
|
51
|
-
this.#occurrence = captureFieldOccurrence(form, this.#path);
|
|
52
|
-
this.#stopPath = form.register(this.#path);
|
|
53
|
-
this.#stopObserve = form.observe(this.#path.split(".")[0]!, () => this.#resolve());
|
|
54
|
-
this.#stopLifetime = form.registerEditorLifetime(this.destroy);
|
|
31
|
+
this.#lifetime = new FieldBindingLifetime(form, schema, schemaVersion);
|
|
55
32
|
const binding = this;
|
|
56
33
|
this.form = {
|
|
57
34
|
get contentLocale() {
|
|
58
35
|
binding.assertActive();
|
|
59
|
-
return
|
|
36
|
+
return form.contentLocale;
|
|
60
37
|
},
|
|
61
38
|
get resource() {
|
|
62
39
|
binding.assertActive();
|
|
@@ -78,68 +55,39 @@ export class FieldEditorBinding<Type extends FieldEditorType> implements FieldBi
|
|
|
78
55
|
}
|
|
79
56
|
|
|
80
57
|
get schema(): SchemaField & { type: Type } {
|
|
81
|
-
|
|
82
|
-
// remains a detached snapshot; values and writes stay revoked immediately.
|
|
83
|
-
this.#resolve();
|
|
84
|
-
return {
|
|
85
|
-
...cloneSchemaField(this.#schema()),
|
|
86
|
-
type: this.#type,
|
|
87
|
-
path: this.#path,
|
|
88
|
-
};
|
|
58
|
+
return { ...this.#lifetime.schema, type: this.#type };
|
|
89
59
|
}
|
|
90
60
|
get stale() {
|
|
91
|
-
this.#
|
|
92
|
-
return this.#stale;
|
|
61
|
+
return this.#lifetime.stale;
|
|
93
62
|
}
|
|
94
63
|
get value(): FieldEditorValue<Type> | null | undefined {
|
|
95
64
|
if (this.stale) return undefined;
|
|
96
|
-
const value = this.#form.get(this.#path);
|
|
65
|
+
const value = this.#form.get(this.#lifetime.path);
|
|
97
66
|
assertEditorValue(this.#type, value);
|
|
98
67
|
return cloneFormValue(value) as FieldEditorValue<Type> | null | undefined;
|
|
99
68
|
}
|
|
100
69
|
get issues() {
|
|
101
|
-
return this.stale
|
|
70
|
+
return this.stale
|
|
71
|
+
? []
|
|
72
|
+
: this.#form.issuesFor(this.#lifetime.path).map((issue) => ({ ...issue }));
|
|
102
73
|
}
|
|
103
74
|
get liveValidation() {
|
|
104
75
|
if (this.stale) return { status: "idle" as const, retry: () => {} };
|
|
105
|
-
const feedback = this.#form.liveValidation.forField(this.#path);
|
|
76
|
+
const feedback = this.#form.liveValidation.forField(this.#lifetime.path);
|
|
106
77
|
return {
|
|
107
78
|
status: feedback.status,
|
|
108
79
|
retry: () => {
|
|
109
80
|
this.assertActive();
|
|
110
|
-
if (!this.readOnly) this.#form.liveValidation.flush(this.#path);
|
|
81
|
+
if (!this.readOnly) this.#form.liveValidation.flush(this.#lifetime.path);
|
|
111
82
|
},
|
|
112
83
|
};
|
|
113
84
|
}
|
|
114
85
|
|
|
86
|
+
get visible() {
|
|
87
|
+
return this.#lifetime.visible;
|
|
88
|
+
}
|
|
115
89
|
get readOnly() {
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
this.#occurrence.ancestors.some(({ schema, resolve }) => {
|
|
119
|
-
const path = resolve();
|
|
120
|
-
return (
|
|
121
|
-
path === undefined ||
|
|
122
|
-
schema.admin.readOnly === true ||
|
|
123
|
-
schema.admin.hidden === true ||
|
|
124
|
-
!this.#form.canRead(path, fieldAccessPath(schema)) ||
|
|
125
|
-
!this.#form.canWrite(path, fieldAccessPath(schema)) ||
|
|
126
|
-
(schema.admin.condition !== undefined &&
|
|
127
|
-
!evaluateFieldCondition(schema.admin.condition, path, (other) => this.#form.get(other)))
|
|
128
|
-
);
|
|
129
|
-
})
|
|
130
|
-
)
|
|
131
|
-
return true;
|
|
132
|
-
const schema = this.#schema();
|
|
133
|
-
const accessPath = fieldAccessPath(schema);
|
|
134
|
-
return (
|
|
135
|
-
this.#form.editingBlocked ||
|
|
136
|
-
schema.admin.readOnly === true ||
|
|
137
|
-
schema.admin.hidden === true ||
|
|
138
|
-
!this.#form.canRead(this.#path, accessPath) ||
|
|
139
|
-
!this.#form.canWrite(this.#path, accessPath) ||
|
|
140
|
-
(schema.admin.condition !== undefined &&
|
|
141
|
-
!evaluateFieldCondition(schema.admin.condition, this.#path, (path) => this.#form.get(path)))
|
|
142
|
-
);
|
|
90
|
+
return this.#lifetime.readOnly;
|
|
143
91
|
}
|
|
144
92
|
get inputProps() {
|
|
145
93
|
const schema = this.schema;
|
|
@@ -167,43 +115,17 @@ export class FieldEditorBinding<Type extends FieldEditorType> implements FieldBi
|
|
|
167
115
|
"This field editor is stale. Use the editor mounted for the current document, locale and field occurrence."
|
|
168
116
|
);
|
|
169
117
|
};
|
|
170
|
-
destroy = () =>
|
|
171
|
-
if (this.#stale) return;
|
|
172
|
-
this.#stale = true;
|
|
173
|
-
this.#stopPath();
|
|
174
|
-
this.#stopObserve();
|
|
175
|
-
this.#stopLifetime();
|
|
176
|
-
};
|
|
118
|
+
destroy = () => this.#lifetime.destroy();
|
|
177
119
|
#write(value: unknown) {
|
|
178
120
|
this.assertActive();
|
|
179
|
-
if (this.readOnly) throw new Error(`Field ${this.#path} is read-only.`);
|
|
121
|
+
if (this.readOnly) throw new Error(`Field ${this.#lifetime.path} is read-only.`);
|
|
180
122
|
assertEditorValue(this.#type, value);
|
|
181
123
|
if (value === undefined) throw new Error("Use null to clear an editor value.");
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (this.#
|
|
186
|
-
|
|
187
|
-
this.#schema().id !== this.#schemaID ||
|
|
188
|
-
this.#schemaVersion() !== this.#initialSchemaVersion ||
|
|
189
|
-
!this.#form.editorScopeActive ||
|
|
190
|
-
this.#form.editorEpoch !== this.#epoch ||
|
|
191
|
-
this.#form.contentLocale !== this.#locale ||
|
|
192
|
-
JSON.stringify(this.#form.resource) !== this.#resource
|
|
193
|
-
) {
|
|
194
|
-
this.destroy();
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
const path = this.#occurrence.resolve();
|
|
198
|
-
if (path === undefined) {
|
|
199
|
-
this.destroy();
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
if (path !== this.#path) {
|
|
203
|
-
this.#stopPath();
|
|
204
|
-
this.#path = path;
|
|
205
|
-
this.#stopPath = this.#form.register(path);
|
|
206
|
-
}
|
|
124
|
+
const detached = cloneFormValue(value) as FieldEditorValue<Type> | null;
|
|
125
|
+
this.assertActive();
|
|
126
|
+
if (this.readOnly) throw new Error(`Field ${this.#lifetime.path} is read-only.`);
|
|
127
|
+
if (this.#apply !== undefined) this.#apply(detached);
|
|
128
|
+
else this.#form.set(this.#lifetime.path, detached);
|
|
207
129
|
}
|
|
208
130
|
}
|
|
209
131
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { resolveBlockTypes } from "@riducms/protocol";
|
|
2
2
|
import type { SchemaField } from "@riducms/protocol";
|
|
3
3
|
import type { FormController } from "@admin/core/forms/form-controller.svelte";
|
|
4
|
-
import { embeddedOccurrences } from "@admin/core/forms/embedded-fields";
|
|
5
4
|
import { scopeRepeatedRowField } from "@admin/fields/nested/scoped-field";
|
|
6
5
|
|
|
7
6
|
type ResolvePath = () => string | undefined;
|
|
@@ -58,20 +57,7 @@ export function captureFieldOccurrence(form: FormController, target: string): Fi
|
|
|
58
57
|
const rowPath = () => {
|
|
59
58
|
const base = path();
|
|
60
59
|
if (base === undefined) return;
|
|
61
|
-
|
|
62
|
-
if (!Array.isArray(values)) return;
|
|
63
|
-
const matches = values
|
|
64
|
-
.map((value: unknown, index) => ({ value, index }))
|
|
65
|
-
.filter(({ value }) => record(value) && value._key === key);
|
|
66
|
-
const match = matches[0];
|
|
67
|
-
if (
|
|
68
|
-
matches.length !== 1 ||
|
|
69
|
-
!match ||
|
|
70
|
-
!record(match.value) ||
|
|
71
|
-
match.value.blockType !== variant
|
|
72
|
-
)
|
|
73
|
-
return;
|
|
74
|
-
return `${base}.${match.index}`;
|
|
60
|
+
return form.rowPath(base, key, variant);
|
|
75
61
|
};
|
|
76
62
|
const children =
|
|
77
63
|
field.type === "array"
|
|
@@ -81,21 +67,18 @@ export function captureFieldOccurrence(form: FormController, target: string): Fi
|
|
|
81
67
|
if (result) return result;
|
|
82
68
|
}
|
|
83
69
|
if (field.plugin?.embeddedTrees !== undefined) {
|
|
84
|
-
const initial =
|
|
85
|
-
(
|
|
86
|
-
|
|
70
|
+
const initial = form
|
|
71
|
+
.embeddedFields({ ...field, path: current })
|
|
72
|
+
.occurrences.find((item) => target.startsWith(`${item.path}.`));
|
|
87
73
|
if (!initial || initial.identity === undefined)
|
|
88
74
|
throw new Error(`Cannot bind ${target}: embedded payloads require stable identities.`);
|
|
89
75
|
const { identity, tree, block, case: branch } = initial;
|
|
90
76
|
const payloadPath = () => {
|
|
91
77
|
const base = path();
|
|
92
78
|
if (base === undefined) return;
|
|
93
|
-
const
|
|
94
|
-
(item) => item.tree.key === tree.key && item.identity === identity
|
|
95
|
-
);
|
|
96
|
-
const match = matches[0];
|
|
79
|
+
const match = form.embeddedOccurrence({ ...field, path: base }, tree.key, identity);
|
|
97
80
|
if (
|
|
98
|
-
|
|
81
|
+
match === undefined ||
|
|
99
82
|
match?.block.slug !== block.slug ||
|
|
100
83
|
match.case.tagValue !== branch.tagValue
|
|
101
84
|
)
|
|
@@ -136,6 +136,7 @@ export class FormController {
|
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
#invalidateEditors() {
|
|
139
|
+
this.#invalidateValueIndexes();
|
|
139
140
|
this.liveValidation.reset();
|
|
140
141
|
this.#listEdits.clear();
|
|
141
142
|
this.editorEpoch += 1;
|
|
@@ -164,6 +165,15 @@ export class FormController {
|
|
|
164
165
|
localizationSources = $state.raw<Readonly<Record<string, string>>>({});
|
|
165
166
|
#registered = new Map<string, Set<symbol>>();
|
|
166
167
|
#rowMountKeys = new WeakMap<object, symbol>();
|
|
168
|
+
#valueEpoch = $state(0);
|
|
169
|
+
#rowIndexes = new Map<string, Map<string, { index: number; variant: unknown }>>();
|
|
170
|
+
#embeddedIndexes = new Map<
|
|
171
|
+
string,
|
|
172
|
+
{
|
|
173
|
+
result: ReturnType<typeof embeddedOccurrences>;
|
|
174
|
+
identities: Map<string, EmbeddedOccurrence | undefined>;
|
|
175
|
+
}
|
|
176
|
+
>();
|
|
167
177
|
#pathObservers = new Map<string, Set<(value: unknown) => void>>();
|
|
168
178
|
#derivedTextBindings = new Map<string, DerivedTextBindingState>();
|
|
169
179
|
#revision = 0;
|
|
@@ -295,6 +305,60 @@ export class FormController {
|
|
|
295
305
|
return readPath(this.values, path);
|
|
296
306
|
}
|
|
297
307
|
|
|
308
|
+
/** Shared identity lookups: a mounted header never scans every sibling on its own. */
|
|
309
|
+
rowPath(path: string, identity: string, variant: unknown) {
|
|
310
|
+
this.#valueEpoch;
|
|
311
|
+
let index = this.#rowIndexes.get(path);
|
|
312
|
+
if (index === undefined) {
|
|
313
|
+
index = new Map();
|
|
314
|
+
const rows = this.get(path);
|
|
315
|
+
if (Array.isArray(rows))
|
|
316
|
+
for (const [position, row] of rows.entries()) {
|
|
317
|
+
if (!isRecord(row) || typeof row._key !== "string") continue;
|
|
318
|
+
index.set(row._key, {
|
|
319
|
+
index: index.has(row._key) ? -1 : position,
|
|
320
|
+
variant: row.blockType,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
this.#rowIndexes.set(path, index);
|
|
324
|
+
}
|
|
325
|
+
const match = index.get(identity);
|
|
326
|
+
return match !== undefined && match.index >= 0 && match.variant === variant
|
|
327
|
+
? `${path}.${match.index}`
|
|
328
|
+
: undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Cache only structural traversal, never access decisions or detached field values. */
|
|
332
|
+
embeddedFields(field: SchemaField) {
|
|
333
|
+
this.#valueEpoch;
|
|
334
|
+
const key = field.path;
|
|
335
|
+
let entry = this.#embeddedIndexes.get(key);
|
|
336
|
+
if (entry === undefined) {
|
|
337
|
+
const result = embeddedOccurrences(field, this.get(field.path), field.path);
|
|
338
|
+
const identities = new Map<string, EmbeddedOccurrence | undefined>();
|
|
339
|
+
for (const occurrence of result.occurrences) {
|
|
340
|
+
const identity = JSON.stringify([occurrence.tree.key, occurrence.identity]);
|
|
341
|
+
identities.set(identity, identities.has(identity) ? undefined : occurrence);
|
|
342
|
+
}
|
|
343
|
+
entry = { result, identities };
|
|
344
|
+
this.#embeddedIndexes.set(key, entry);
|
|
345
|
+
}
|
|
346
|
+
return entry.result;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
embeddedOccurrence(field: SchemaField, treeKey: string, identity: string) {
|
|
350
|
+
this.embeddedFields(field);
|
|
351
|
+
return this.#embeddedIndexes
|
|
352
|
+
.get(field.path)
|
|
353
|
+
?.identities.get(JSON.stringify([treeKey, identity]));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#invalidateValueIndexes() {
|
|
357
|
+
this.#rowIndexes.clear();
|
|
358
|
+
this.#embeddedIndexes.clear();
|
|
359
|
+
this.#valueEpoch++;
|
|
360
|
+
}
|
|
361
|
+
|
|
298
362
|
/** DOM identity survives retained-row replacements, but not removal and key reuse. */
|
|
299
363
|
rowMountKey(row: Record<string, unknown>) {
|
|
300
364
|
const key = this.#rowMountKeys.get(row) ?? Symbol();
|
|
@@ -333,6 +397,7 @@ export class FormController {
|
|
|
333
397
|
if (key) mounts.set(token, { row, key });
|
|
334
398
|
}
|
|
335
399
|
writePath(this.values, path, value);
|
|
400
|
+
this.#invalidateValueIndexes();
|
|
336
401
|
if (root) {
|
|
337
402
|
const retained = new Set<string>();
|
|
338
403
|
for (const { token, schema, path: fieldPath, value: current } of indexFieldValues(
|
|
@@ -540,6 +605,7 @@ export class FormController {
|
|
|
540
605
|
this.#invalidateEditors();
|
|
541
606
|
this.#clearDerivedTextBindings();
|
|
542
607
|
this.values = cloneFormValues(values);
|
|
608
|
+
this.#invalidateValueIndexes();
|
|
543
609
|
this.original = cloneFormValues(values);
|
|
544
610
|
this.localizationSources = {};
|
|
545
611
|
this.issues = [];
|
|
@@ -558,6 +624,7 @@ export class FormController {
|
|
|
558
624
|
this.#fields = nextFields;
|
|
559
625
|
const result = reconcileFormSchema(this, previousFields, nextFields, { initializeDefaults });
|
|
560
626
|
this.values = result.values;
|
|
627
|
+
this.#invalidateValueIndexes();
|
|
561
628
|
this.original = result.original;
|
|
562
629
|
this.issues = [];
|
|
563
630
|
this.#submittingIssues = [];
|
|
@@ -575,6 +642,7 @@ export class FormController {
|
|
|
575
642
|
this.#fields = nextFields;
|
|
576
643
|
const result = recoverFormDraft(this, draft, previousFields, nextFields);
|
|
577
644
|
this.values = result.values;
|
|
645
|
+
this.#invalidateValueIndexes();
|
|
578
646
|
this.original = result.original;
|
|
579
647
|
this.issues = [];
|
|
580
648
|
this.#submittingIssues = [];
|
|
@@ -93,6 +93,7 @@ export function guardPluginAuthoring(
|
|
|
93
93
|
},
|
|
94
94
|
}),
|
|
95
95
|
...(host.schemaForm === undefined ? {} : { schemaForm: host.schemaForm }),
|
|
96
|
+
...(host.schemaHeader === undefined ? {} : { schemaHeader: host.schemaHeader }),
|
|
96
97
|
...(host.schemaIssues === undefined
|
|
97
98
|
? {}
|
|
98
99
|
: {
|