@riducms/plugin 0.1.4 → 0.2.0
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/README.md +346 -32
- package/package.json +9 -4
- package/src/admin.ts +171 -0
- package/src/authoring/v1.ts +71 -0
- package/src/authoring.ts +160 -2
- package/src/component-config.ts +51 -0
- package/src/editor/field.svelte +21 -0
- package/src/editor/field.ts +9 -0
- package/src/editor/index.ts +3 -0
- package/src/editor/registry.ts +174 -0
- package/src/editor/types.ts +104 -0
- package/src/field.ts +596 -16
- package/src/form.ts +33 -4
- package/src/i18n.ts +29 -4
- package/src/index.ts +19 -7
- package/src/local-row-label.ts +87 -0
- package/src/plugin-registry.ts +188 -0
- package/src/plugin.ts +265 -74
- package/src/row-label.ts +15 -4
package/src/authoring.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { SchemaCollection, SchemaField } from "@riducms/protocol";
|
|
2
|
-
import type { Component } from "svelte";
|
|
1
|
+
import type { SchemaCollection, SchemaField, ValidationIssue } from "@riducms/protocol";
|
|
2
|
+
import type { Component, Snippet } from "svelte";
|
|
3
3
|
|
|
4
|
+
/** A saved document returned by Ridu. Check custom fields before using their unknown values. */
|
|
4
5
|
export type FieldDocument = Record<string, unknown> & {
|
|
5
6
|
id: string;
|
|
6
7
|
createdAt?: string;
|
|
@@ -13,6 +14,7 @@ export type FieldDocument = Record<string, unknown> & {
|
|
|
13
14
|
};
|
|
14
15
|
};
|
|
15
16
|
|
|
17
|
+
/** A condition that narrows documents offered by the reference browser. It does not grant access. */
|
|
16
18
|
export interface FieldReferenceFilter {
|
|
17
19
|
field: string;
|
|
18
20
|
operator:
|
|
@@ -27,28 +29,184 @@ export interface FieldReferenceFilter {
|
|
|
27
29
|
value: string | number | boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Props for Ridu's related-document picker and editor. Render the component supplied
|
|
34
|
+
* as `authoring.referenceBrowser`; your `onCommit` decides how selected IDs change
|
|
35
|
+
* your field. Saving a related document inside the browser is a separate server save.
|
|
36
|
+
*/
|
|
30
37
|
export interface FieldReferenceBrowserProps {
|
|
38
|
+
/** Whether the browser is visible. */
|
|
31
39
|
open?: boolean;
|
|
40
|
+
/** The relationship/upload field whose rules the browser should use. */
|
|
32
41
|
field: SchemaField;
|
|
42
|
+
/** The target collection's resolved schema, available from `authoring.collections`. */
|
|
33
43
|
collection: SchemaCollection;
|
|
44
|
+
/** Allow multiple selections when true; otherwise select at most one document. */
|
|
34
45
|
hasMany: boolean;
|
|
46
|
+
/** IDs currently selected by your editor. */
|
|
35
47
|
selectedIDs: readonly string[];
|
|
48
|
+
/** Further restrict editing; false cannot override Ridu's access restrictions. */
|
|
36
49
|
readOnly?: boolean;
|
|
50
|
+
/** Open this already-loaded document for editing instead of starting at the list. */
|
|
37
51
|
initialDocument?: FieldDocument;
|
|
52
|
+
/** Fetch and open this document when `initialDocument` is not supplied. */
|
|
38
53
|
initialDocumentID?: string;
|
|
54
|
+
/** Limit selectable documents; the server still applies its own access rules. */
|
|
39
55
|
optionFilter?: FieldReferenceFilter | readonly FieldReferenceFilter[];
|
|
56
|
+
/** Initial values for a new related document, not changes to an existing document. */
|
|
40
57
|
defaultValues?: Readonly<Record<string, unknown>>;
|
|
58
|
+
/** Allow creation when access permits it. Set false to hide creation. */
|
|
41
59
|
allowCreate?: boolean;
|
|
60
|
+
/** Content locale for loading and editing related documents. */
|
|
42
61
|
locale?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Called when the user confirms the selection. Update your field here.
|
|
64
|
+
* Return false (or resolve to false) to keep the browser open. Returning void or
|
|
65
|
+
* true accepts the selection and closes it while editing remains allowed. A thrown
|
|
66
|
+
* error/rejected promise keeps it open and Ridu displays an error notification.
|
|
67
|
+
*/
|
|
43
68
|
onCommit: (ids: string[]) => void | boolean | Promise<void | boolean>;
|
|
69
|
+
/** Called when the browser closes; update your open/closed UI state here. */
|
|
44
70
|
onClose: () => void;
|
|
45
71
|
}
|
|
46
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Select an existing group of ordinary fields inside a plugin value, such as the
|
|
75
|
+
* fields of one rich-text card. Go must declare the embedded tree and its schemas.
|
|
76
|
+
* Ridu locates the data by its stable identity, even when the card moves.
|
|
77
|
+
*/
|
|
78
|
+
export interface EmbeddedSchemaFormScope {
|
|
79
|
+
/** The embedded tree key declared by the Go field, for example `"widgets"`. */
|
|
80
|
+
treeKey: string;
|
|
81
|
+
/** The existing item's stable ID, as declared by that tree's identity configuration. */
|
|
82
|
+
identity: string;
|
|
83
|
+
/** Display without editing; false does not override document or field access rules. */
|
|
84
|
+
readOnly?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Select a Go-declared embedded variant when creating or copying an item's field data. */
|
|
88
|
+
export interface EmbeddedSchemaVariantScope {
|
|
89
|
+
/** The embedded tree key declared by the Go field. */
|
|
90
|
+
treeKey: string;
|
|
91
|
+
/** The tree case's configured tag value, not the name of the property holding the tag. */
|
|
92
|
+
caseTag: string;
|
|
93
|
+
/** The variant key within that case, for example `"hero"`. */
|
|
94
|
+
variantSlug: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A temporary Apply/Cancel form for fields inside one plugin item.
|
|
99
|
+
* Its edits stay separate from the document until your Apply handler accepts them.
|
|
100
|
+
* This is not a saved document draft/version and cannot save to the server itself.
|
|
101
|
+
* Ridu tracks pending edits so the outer form can ask the user to Apply or Cancel.
|
|
102
|
+
*/
|
|
103
|
+
export interface EmbeddedSchemaDraft {
|
|
104
|
+
/** Unique ID for this temporary editing session. */
|
|
105
|
+
readonly id: string;
|
|
106
|
+
/** Stable ID of the item being edited, or the proposed ID for a new item. */
|
|
107
|
+
readonly identity: string;
|
|
108
|
+
/** Whether edits await Apply/Cancel. A proposed new item counts as dirty even before typing. */
|
|
109
|
+
readonly dirty: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Whether the draft is no longer usable. Removal, changed source data, document/
|
|
112
|
+
* locale/schema/access changes, discard, or closing the parent editor can expire it.
|
|
113
|
+
* Reopen a draft instead of applying old data. Moving the same item alone is allowed.
|
|
114
|
+
*/
|
|
115
|
+
readonly stale: boolean;
|
|
116
|
+
/** Current validation issues shown in this draft. */
|
|
117
|
+
readonly issues: readonly ValidationIssue[];
|
|
118
|
+
/** Copy the draft's current field values; throws if it has expired or editing is blocked. */
|
|
119
|
+
payload(): Record<string, unknown>;
|
|
120
|
+
/**
|
|
121
|
+
* Check the draft against its declared field rules and update `issues`.
|
|
122
|
+
* Returns whether it passes. Go validators and hooks run later, on document save.
|
|
123
|
+
* The guarded plugin method throws if the draft has expired or editing is blocked.
|
|
124
|
+
*/
|
|
125
|
+
validate(): boolean;
|
|
126
|
+
/** Release this draft and abandon its pending edits. Safe to call more than once. */
|
|
127
|
+
discard(): void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Options for rendering a draft in Ridu's drawer with Apply and Cancel controls. */
|
|
131
|
+
export interface EmbeddedSchemaDraftEditorProps {
|
|
132
|
+
/** A draft created by this field's `authoring.beginSchemaDraft`. */
|
|
133
|
+
draft: EmbeddedSchemaDraft;
|
|
134
|
+
/** Optional heading for the drawer. */
|
|
135
|
+
title?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Receives checked field data when Apply succeeds. Insert or update the item in
|
|
138
|
+
* your plugin value and call `field.set` (or serialize your editor's updated state).
|
|
139
|
+
* Close your drawer UI here. Ridu discards the temporary draft after this handler
|
|
140
|
+
* returns; it does not insert data into your plugin value or save the document.
|
|
141
|
+
*/
|
|
142
|
+
onApply: (payload: Record<string, unknown>) => void;
|
|
143
|
+
/** Close your editor UI after the pending draft has been discarded. */
|
|
144
|
+
onCancel: () => void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Ridu tools supplied to a plugin field for embedded forms and related documents.
|
|
149
|
+
* Optional methods must be checked before use. They work within the current field's
|
|
150
|
+
* lifetime: async reads/requests reject if the field expires before they complete.
|
|
151
|
+
* Already-dispatched server work is not undone. Cancel requests when appropriate,
|
|
152
|
+
* and prevent an older request from overwriting a newer result in your own UI.
|
|
153
|
+
*/
|
|
47
154
|
export interface FieldAuthoringHost {
|
|
155
|
+
/**
|
|
156
|
+
* Render the ordinary fields of an existing embedded item using
|
|
157
|
+
* `{@render authoring.schemaForm({ treeKey, identity })}`.
|
|
158
|
+
* Edits update the parent document's unsaved form immediately. Use a draft below
|
|
159
|
+
* if the user needs Apply/Cancel. Neither approach saves the document itself.
|
|
160
|
+
*/
|
|
161
|
+
schemaForm?: Snippet<[EmbeddedSchemaFormScope]>;
|
|
162
|
+
/**
|
|
163
|
+
* Start a temporary Apply/Cancel form. Pass `{ treeKey, identity }` to edit an
|
|
164
|
+
* existing item, or `{ treeKey, caseTag, variantSlug }` to prepare a new one using
|
|
165
|
+
* Go-declared field defaults. Creation does not insert anything into your value.
|
|
166
|
+
* Call `discard` when abandoning a draft; closing the field also cleans it up.
|
|
167
|
+
*/
|
|
168
|
+
beginSchemaDraft?: (
|
|
169
|
+
scope: EmbeddedSchemaFormScope | EmbeddedSchemaVariantScope
|
|
170
|
+
) => EmbeddedSchemaDraft;
|
|
171
|
+
/** Render a draft in Ridu's drawer; your `onApply` writes the accepted data into your plugin value. */
|
|
172
|
+
schemaDraftEditor?: Snippet<[EmbeddedSchemaDraftEditorProps]>;
|
|
173
|
+
/** Read current parent-form issues for an existing embedded item, for example for a card badge. */
|
|
174
|
+
schemaIssues?: (scope: EmbeddedSchemaFormScope) => readonly ValidationIssue[];
|
|
175
|
+
/**
|
|
176
|
+
* Copy an item's ordinary field data and give its schema-declared nested rows/items
|
|
177
|
+
* fresh IDs. Use when duplicating to avoid sharing identities. This does not insert
|
|
178
|
+
* the copy or copy your outer plugin item; update that structure in your editor.
|
|
179
|
+
*/
|
|
180
|
+
copySchemaPayload?: (
|
|
181
|
+
scope: EmbeddedSchemaVariantScope,
|
|
182
|
+
payload: Readonly<Record<string, unknown>>
|
|
183
|
+
) => Record<string, unknown>;
|
|
184
|
+
/** Collection definitions available in the current admin schema, not fetched documents. */
|
|
48
185
|
readonly collections: readonly SchemaCollection[];
|
|
186
|
+
/**
|
|
187
|
+
* Admin change counter: it advances when Ridu is notified that documents changed.
|
|
188
|
+
* Useful for refreshing lookup UI; it is not a document's saved `_revision` number.
|
|
189
|
+
*/
|
|
49
190
|
readonly documentRevision: number;
|
|
191
|
+
/** The content locale being edited, separate from the admin interface language. */
|
|
50
192
|
readonly locale?: string;
|
|
193
|
+
/** Related-document picker/editor component. Its `onCommit` handler updates your field. */
|
|
51
194
|
referenceBrowser: Component<FieldReferenceBrowserProps>;
|
|
195
|
+
/**
|
|
196
|
+
* Fetch a saved document by collection slug and ID in the current content locale.
|
|
197
|
+
* Does not include unsaved edits from the current form. Rejects on request failure
|
|
198
|
+
* or if this field expires while waiting. Returns a copy; edits to it are local.
|
|
199
|
+
*/
|
|
52
200
|
findDocument(collection: string, id: string, signal?: AbortSignal): Promise<FieldDocument>;
|
|
201
|
+
/**
|
|
202
|
+
* POST to an endpoint declared by this renderer's paired Go plugin. Supply the
|
|
203
|
+
* plugin-relative path, not an arbitrary URL. This sends a real server request;
|
|
204
|
+
* it does not automatically update or save the containing form.
|
|
205
|
+
*
|
|
206
|
+
* Requires an editable field and rejects if the field expires while waiting.
|
|
207
|
+
* Server work already sent may still finish. The `Result` generic describes your
|
|
208
|
+
* expected response; it does not validate it. Use `unknown` and decode the result
|
|
209
|
+
* when the response shape needs checking.
|
|
210
|
+
*/
|
|
53
211
|
requestPlugin?<Result>(path: string, body: unknown, signal?: AbortSignal): Promise<Result>;
|
|
54
212
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Validates the explicit settings selected for a static component before decoding. */
|
|
2
|
+
export function decodeComponentConfig(
|
|
3
|
+
selection: { config?: unknown } | undefined,
|
|
4
|
+
decoder: ((value: unknown) => unknown) | undefined
|
|
5
|
+
): unknown {
|
|
6
|
+
const supplied = selection !== undefined && Object.hasOwn(selection, "config");
|
|
7
|
+
if (!supplied) {
|
|
8
|
+
if (decoder !== undefined)
|
|
9
|
+
throw new Error("config is required by decodeConfig; supply a configuration object in Go");
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
if (decoder === undefined)
|
|
13
|
+
throw new Error(
|
|
14
|
+
"config was supplied but there is no decodeConfig; remove the Go configuration or register a decoder"
|
|
15
|
+
);
|
|
16
|
+
if (typeof decoder !== "function") throw new Error("decodeConfig must be a synchronous function");
|
|
17
|
+
const raw = selection.config;
|
|
18
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
19
|
+
throw new Error("supplied config must be a finite JSON object");
|
|
20
|
+
const seen = new Set<object>();
|
|
21
|
+
const copy = (value: unknown, depth: number): unknown => {
|
|
22
|
+
if (depth > 100) throw new Error("config exceeds the supported JSON depth");
|
|
23
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
24
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
25
|
+
if (
|
|
26
|
+
typeof value !== "object" ||
|
|
27
|
+
(!Array.isArray(value) &&
|
|
28
|
+
Object.getPrototypeOf(value) !== Object.prototype &&
|
|
29
|
+
Object.getPrototypeOf(value) !== null) ||
|
|
30
|
+
seen.has(value)
|
|
31
|
+
)
|
|
32
|
+
throw new Error("config must contain finite, acyclic JSON data");
|
|
33
|
+
seen.add(value);
|
|
34
|
+
const result = Array.isArray(value)
|
|
35
|
+
? Array.from(value, (child) => copy(child, depth + 1))
|
|
36
|
+
: Object.fromEntries(
|
|
37
|
+
Object.entries(value).map(([key, child]) => [key, copy(child, depth + 1)])
|
|
38
|
+
);
|
|
39
|
+
seen.delete(value);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
const decoded = decoder(copy(raw, 0));
|
|
43
|
+
if (
|
|
44
|
+
decoded !== null &&
|
|
45
|
+
(typeof decoded === "object" || typeof decoded === "function") &&
|
|
46
|
+
"then" in decoded &&
|
|
47
|
+
typeof decoded.then === "function"
|
|
48
|
+
)
|
|
49
|
+
throw new Error("config decoders must be synchronous");
|
|
50
|
+
return decoded;
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import { FieldFrame } from "@riducms/ui";
|
|
4
|
+
import type { FieldBinding } from "./types";
|
|
5
|
+
|
|
6
|
+
let { field, children }: { field: Pick<FieldBinding, "schema" | "issues">; children: Snippet } =
|
|
7
|
+
$props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<div data-field-path={field.schema.path}>
|
|
11
|
+
<FieldFrame
|
|
12
|
+
controlID={field.schema.id}
|
|
13
|
+
label={field.schema.admin.label}
|
|
14
|
+
required={field.schema.required}
|
|
15
|
+
readOnly={field.schema.admin.readOnly}
|
|
16
|
+
description={field.schema.admin.description}
|
|
17
|
+
errors={field.issues.map((issue) => issue.message)}
|
|
18
|
+
>
|
|
19
|
+
{@render children()}
|
|
20
|
+
</FieldFrame>
|
|
21
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds a label, description and validation messages around your custom field input.
|
|
3
|
+
*
|
|
4
|
+
* Import `Field from "@riducms/plugin/editor/field"` and wrap your input in
|
|
5
|
+
* `<Field {field}>...</Field>` to show its label, description and validation errors.
|
|
6
|
+
* Your input still needs its value, read-only state and change handler. The wrapper
|
|
7
|
+
* does not register the field or own form values; Ridu already does that.
|
|
8
|
+
*/
|
|
9
|
+
export { default } from "./field.svelte";
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { bindSchemaManifest } from "@riducms/protocol";
|
|
2
|
+
import { resolveBlockTypes } from "@riducms/protocol";
|
|
3
|
+
import type { Component } from "svelte";
|
|
4
|
+
import type { SchemaField, SchemaManifest } from "@riducms/protocol";
|
|
5
|
+
|
|
6
|
+
import type { FieldEditorProps, FieldEditorType } from "./types";
|
|
7
|
+
import { decodeComponentConfig } from "../component-config";
|
|
8
|
+
|
|
9
|
+
export const localEditorReference = /^app:[A-Za-z][A-Za-z0-9_]*$/;
|
|
10
|
+
const editorTypes = new Set<string>([
|
|
11
|
+
"text",
|
|
12
|
+
"textarea",
|
|
13
|
+
"email",
|
|
14
|
+
"date",
|
|
15
|
+
"code",
|
|
16
|
+
"number",
|
|
17
|
+
"checkbox",
|
|
18
|
+
"text-list",
|
|
19
|
+
"number-list",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A custom field input and its optional settings decoder. `type` must match
|
|
24
|
+
* the Go field's type. When Config is not undefined, `decodeConfig` is required;
|
|
25
|
+
* naming a config type alone does not check serialized settings.
|
|
26
|
+
*/
|
|
27
|
+
export type FieldEditorDefinition<Type extends FieldEditorType, Config = undefined> = {
|
|
28
|
+
/** The existing field type to display differently, for example `"text"` or `"number"`. */
|
|
29
|
+
type: Type;
|
|
30
|
+
/** Svelte component accepting the matching `FieldEditorProps<Type, Config>`. */
|
|
31
|
+
component: Component<FieldEditorProps<NoInfer<Type>, NoInfer<Config>>>;
|
|
32
|
+
} & (0 extends 1 & Config
|
|
33
|
+
? { decodeConfig: (value: unknown) => Config }
|
|
34
|
+
: [Config] extends [never]
|
|
35
|
+
? { decodeConfig: (value: unknown) => Config }
|
|
36
|
+
: [Config] extends [undefined]
|
|
37
|
+
? { decodeConfig?: (value: unknown) => Config }
|
|
38
|
+
: { decodeConfig: (value: unknown) => Config });
|
|
39
|
+
|
|
40
|
+
const registration = Symbol("ridu-field-editor");
|
|
41
|
+
const registeredEditors = new WeakSet<RegisteredFieldEditor>();
|
|
42
|
+
/** Registration returned by `defineFieldEditor`; store it in `defineAdmin({ fields: ... })`. */
|
|
43
|
+
export interface RegisteredFieldEditor {
|
|
44
|
+
readonly [registration]: true;
|
|
45
|
+
readonly type: FieldEditorType;
|
|
46
|
+
// Erasure must not advertise that a specific component accepts every value/config.
|
|
47
|
+
// Only the validated host render boundary may recover its props.
|
|
48
|
+
readonly component: Component<never>;
|
|
49
|
+
decode(field: SchemaField): unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Register a Svelte component to replace a field input in your application.
|
|
54
|
+
* Supports text, textarea, email, date, code, number, checkbox, text-list and number-list fields.
|
|
55
|
+
*
|
|
56
|
+
* Put the result under an `app:name` key in `defineAdmin({ fields: ... })` and
|
|
57
|
+
* select that same key in Go with `field.Admin{Editor: field.Component("app:name")}`. The Go field keeps
|
|
58
|
+
* its original storage, validation and filtering behaviour.
|
|
59
|
+
*
|
|
60
|
+
* `type` and `component` are required. The component receives `FieldEditorProps`
|
|
61
|
+
* for that type: read `field.value`, update the unsaved form with `field.set`, and
|
|
62
|
+
* render labels/errors with the `Field` component from `@riducms/plugin/editor/field`.
|
|
63
|
+
* Ridu provides the value type, so this helper needs no `decodeValue` option.
|
|
64
|
+
*
|
|
65
|
+
* Add `decodeConfig` only when Go supplies settings through `field.Component`.
|
|
66
|
+
* It checks unknown JSON and returns the component's `config` prop synchronously;
|
|
67
|
+
* throw a useful error for invalid settings. With a decoder, Go must supply an
|
|
68
|
+
* object even for empty settings. Without one, omit the settings argument in Go.
|
|
69
|
+
* TypeScript infers the `Config` type from the decoder's return value.
|
|
70
|
+
*
|
|
71
|
+
* @param definition The existing field type, Svelte component, and optional settings decoder.
|
|
72
|
+
* @returns A frozen registration for `defineAdmin`'s `fields` map. This does not
|
|
73
|
+
* render the component, select it on a Go field, or change the stored value type.
|
|
74
|
+
* @throws If the type is unsupported or the component is invalid. `ridu check`
|
|
75
|
+
* also checks the selected Go field type and its settings before deployment.
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* import { defineAdmin } from '@riducms/plugin/admin';
|
|
79
|
+
* import { defineFieldEditor } from '@riducms/plugin/editor';
|
|
80
|
+
* import { generatedAdminPlugins } from './ridu.plugins.generated';
|
|
81
|
+
* import TitleField from './components/title-field.svelte';
|
|
82
|
+
*
|
|
83
|
+
* export default defineAdmin({
|
|
84
|
+
* plugins: generatedAdminPlugins,
|
|
85
|
+
* fields: {
|
|
86
|
+
* 'app:titleCounter': defineFieldEditor({ type: 'text', component: TitleField })
|
|
87
|
+
* }
|
|
88
|
+
* });
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function defineFieldEditor<const Type extends FieldEditorType, Config = undefined>(
|
|
92
|
+
definition: FieldEditorDefinition<Type, Config>
|
|
93
|
+
): RegisteredFieldEditor {
|
|
94
|
+
if (!editorTypes.has(definition.type) || typeof definition.component !== "function")
|
|
95
|
+
throw new Error("A field editor requires a supported field type and a Svelte component.");
|
|
96
|
+
const { type, component, decodeConfig } = definition;
|
|
97
|
+
const editor = Object.freeze({
|
|
98
|
+
[registration]: true as const,
|
|
99
|
+
type,
|
|
100
|
+
component,
|
|
101
|
+
decode(field: SchemaField): unknown {
|
|
102
|
+
const reference = field.admin.editor?.reference;
|
|
103
|
+
if (field.type !== type)
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Editor ${reference} expects ${type}, but ${field.path} is ${field.type}. Change the Go editor selection or registration type.`
|
|
106
|
+
);
|
|
107
|
+
try {
|
|
108
|
+
return decodeComponentConfig(field.admin.editor, decodeConfig);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Editor ${reference} config for ${field.path} is invalid: ${error instanceof Error ? error.message : String(error)}. Fix the field component config in Go or the decoder in admin/src/admin.config.ts.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
registeredEditors.add(editor);
|
|
117
|
+
return editor;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface FieldEditorConfig {
|
|
121
|
+
/** Custom field inputs selected by Go `field.Admin{Editor: field.Component("app:name")}`. Use `defineFieldEditor` for each entry. */
|
|
122
|
+
fields?: Readonly<Record<`app:${string}`, RegisteredFieldEditor>>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The application owns one static configuration; packaged plugins retain their pairing contracts. */
|
|
126
|
+
export function validateFieldEditorRegistrations(config: FieldEditorConfig): void {
|
|
127
|
+
for (const [reference, editor] of Object.entries(config.fields ?? {})) {
|
|
128
|
+
if (!localEditorReference.test(reference))
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Invalid editor reference ${reference}; expected app:name in admin/src/admin.config.ts.`
|
|
131
|
+
);
|
|
132
|
+
if (!registeredEditors.has(editor))
|
|
133
|
+
throw new Error(`Editor ${reference} must use defineFieldEditor.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Uses the same executable registrations and decoders in the browser and build checks. */
|
|
138
|
+
export function validateAdminEditors(
|
|
139
|
+
config: FieldEditorConfig,
|
|
140
|
+
manifest: Pick<SchemaManifest, "collections" | "globals" | "blocks">
|
|
141
|
+
): void {
|
|
142
|
+
validateFieldEditorRegistrations(config);
|
|
143
|
+
const failures: string[] = [];
|
|
144
|
+
bindSchemaManifest(manifest);
|
|
145
|
+
const inspect = (fields: readonly SchemaField[], owner: string) => {
|
|
146
|
+
for (const field of fields) {
|
|
147
|
+
const reference = field.admin.editor?.reference;
|
|
148
|
+
if (reference !== undefined) {
|
|
149
|
+
const editor = config.fields?.[reference as `app:${string}`];
|
|
150
|
+
try {
|
|
151
|
+
if (!localEditorReference.test(reference))
|
|
152
|
+
throw new Error(`Malformed editor reference ${reference}; expected app:name.`);
|
|
153
|
+
if (editor === undefined)
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Editor ${reference} is not registered. Register it in admin/src/admin.config.ts or change the field component in Go.`
|
|
156
|
+
);
|
|
157
|
+
editor.decode(field);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
failures.push(
|
|
160
|
+
`${owner}.${field.path}: ${error instanceof Error ? error.message : String(error)}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const tree of field.plugin?.embeddedTrees ?? [])
|
|
165
|
+
for (const item of tree.cases)
|
|
166
|
+
for (const variant of resolveBlockTypes(item)) inspect(variant.fields, owner);
|
|
167
|
+
if (field.nested !== undefined) inspect(field.nested.fields, owner);
|
|
168
|
+
for (const block of resolveBlockTypes(field.blocks) ?? []) inspect(block.fields, owner);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
for (const collection of manifest.collections) inspect(collection.fields, collection.slug);
|
|
172
|
+
for (const global of manifest.globals ?? []) inspect(global.fields, global.slug);
|
|
173
|
+
if (failures.length !== 0) throw new Error(failures.join("\n"));
|
|
174
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { SchemaField, ValidationIssue } from "@riducms/protocol";
|
|
2
|
+
import type { FieldAuthoringHost } from "../authoring";
|
|
3
|
+
import type { FieldDocumentForm, FieldLiveValidation } from "../form";
|
|
4
|
+
import type { AdminI18n } from "../i18n";
|
|
5
|
+
|
|
6
|
+
/** Field types whose inputs you can replace with a custom component. Lists contain primitive strings or numbers; the list is one field. */
|
|
7
|
+
export type FieldEditorType =
|
|
8
|
+
| "text"
|
|
9
|
+
| "textarea"
|
|
10
|
+
| "email"
|
|
11
|
+
| "date"
|
|
12
|
+
| "code"
|
|
13
|
+
| "number"
|
|
14
|
+
| "checkbox"
|
|
15
|
+
| "text-list"
|
|
16
|
+
| "number-list";
|
|
17
|
+
/** The value your field component reads and writes. Date and code fields contain strings. */
|
|
18
|
+
export type FieldEditorValue<Type extends FieldEditorType> = Type extends "text-list"
|
|
19
|
+
? string[]
|
|
20
|
+
: Type extends "number-list"
|
|
21
|
+
? number[]
|
|
22
|
+
: Type extends "number"
|
|
23
|
+
? number
|
|
24
|
+
: Type extends "checkbox"
|
|
25
|
+
? boolean
|
|
26
|
+
: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The current field value, validation messages and input controls for your component.
|
|
30
|
+
* Ridu registers it and cleans it up. In arrays/blocks it follows the row's stable
|
|
31
|
+
* `_key` through reorder. Removing the row, closing the editor, or replacing the
|
|
32
|
+
* document, locale, schema or saved/reset values makes it stale. Old connections
|
|
33
|
+
* cannot edit a new row that happens to occupy the same position.
|
|
34
|
+
*/
|
|
35
|
+
export interface FieldBinding<Type extends FieldEditorType = FieldEditorType> {
|
|
36
|
+
/**
|
|
37
|
+
* A copy of the resolved field definition, including its current path, label and rules.
|
|
38
|
+
* `schema.admin.readOnly` reflects field configuration and access, not a pending document save.
|
|
39
|
+
*/
|
|
40
|
+
readonly schema: SchemaField & { type: Type };
|
|
41
|
+
/** Current form value, including unsaved edits; null/undefined mean empty or absent. */
|
|
42
|
+
readonly value: FieldEditorValue<Type> | null | undefined;
|
|
43
|
+
/** Current validation issues, including errors returned by a document save. */
|
|
44
|
+
readonly issues: readonly ValidationIssue[];
|
|
45
|
+
/** Opted-in server feedback, managed by the host; save validation stays independent. */
|
|
46
|
+
readonly liveValidation: FieldLiveValidation;
|
|
47
|
+
/** Whether changes are blocked right now, including during a document save. `set` checks this again when called. */
|
|
48
|
+
readonly readOnly: boolean;
|
|
49
|
+
/** True once the connection expires. Value becomes undefined, issues empty and writes throw. */
|
|
50
|
+
readonly stale: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* ID, name and accessibility attributes for your input. Spread these onto the
|
|
53
|
+
* control, and handle `field.readOnly` and value changes separately.
|
|
54
|
+
* The optional Field wrapper supplies the matching label and error messages.
|
|
55
|
+
* Native `required` is false for boolean and list controls: a list requires
|
|
56
|
+
* entries, not nonempty text in every item. Ridu validates the container on save.
|
|
57
|
+
*/
|
|
58
|
+
readonly inputProps: {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
required: boolean;
|
|
62
|
+
"aria-invalid": boolean;
|
|
63
|
+
"aria-describedby": string | undefined;
|
|
64
|
+
"aria-errormessage": string | undefined;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Change the value in the unsaved form; `null` clears it. This does not save to
|
|
68
|
+
* the server. Throws if stale, read-only, or given the wrong value type. Lists are copied on reads and writes.
|
|
69
|
+
* Normal field validation and server authorization still apply on document save.
|
|
70
|
+
*/
|
|
71
|
+
set(value: FieldEditorValue<Type> | null): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Props for a custom field input selected by Go `field.Admin{Editor: field.Component("app:name")}` and
|
|
76
|
+
* registered with `defineFieldEditor` in `admin/src/admin.config.ts`.
|
|
77
|
+
* Use `FieldEditorProps<"text", MyConfig>` for a text editor with decoded settings.
|
|
78
|
+
*/
|
|
79
|
+
export type FieldEditorProps<Type extends FieldEditorType = FieldEditorType, Config = undefined> = {
|
|
80
|
+
/** Read the current value and update it with `field.set(...)`. */
|
|
81
|
+
field: FieldBinding<Type>;
|
|
82
|
+
/** Interface translations and formatting using the admin's language/preferences. */
|
|
83
|
+
i18n: AdminI18n;
|
|
84
|
+
/**
|
|
85
|
+
* Read other values in this unsaved document form. These methods return copies
|
|
86
|
+
* and expire with the editor. Local editors update only their own `field`;
|
|
87
|
+
* this form interface does not expose `bind` for writing another field.
|
|
88
|
+
*/
|
|
89
|
+
form: Pick<FieldDocumentForm, "contentLocale" | "resource" | "get" | "issuesFor"> &
|
|
90
|
+
Partial<Pick<FieldDocumentForm, "snapshot">>;
|
|
91
|
+
/**
|
|
92
|
+
* Browse or fetch related documents. These are the local editor's available tools;
|
|
93
|
+
* a local editor mounted in an embedded form reads and writes that form’s detached payload.
|
|
94
|
+
* The containing paired plugin owns Apply/Cancel and Go plugin requests.
|
|
95
|
+
*/
|
|
96
|
+
authoring: Pick<
|
|
97
|
+
FieldAuthoringHost,
|
|
98
|
+
"collections" | "documentRevision" | "referenceBrowser" | "findDocument"
|
|
99
|
+
> & { readonly locale: string | undefined };
|
|
100
|
+
} & ([Config] extends [undefined]
|
|
101
|
+
? { config?: never }
|
|
102
|
+
: {
|
|
103
|
+
/** Go settings validated by the registration's decoder before rendering. */ config: Config;
|
|
104
|
+
});
|