@jini-ai/admin 0.4.12 → 0.4.13
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/CHANGELOG.md +4 -0
- package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
- package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
- package/dist/agent-plugins/package-file-tree.js +54 -51
- package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
- package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
- package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
- package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
- package/dist/browser/navigation.js +44 -27
- package/dist/comments/adapters/memory.js +30 -12
- package/dist/comments/conformance/comments-api.conformance.js +55 -33
- package/dist/comments/controllers/comment-queue.controller.js +13 -4
- package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
- package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
- package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
- package/dist/comments/react/index.d.ts +2 -1
- package/dist/comments/react/index.js +1 -1
- package/dist/consent/react/ConsentFields.d.ts +35 -0
- package/dist/consent/react/ConsentFields.js +74 -0
- package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
- package/dist/consent/react/ConsentPreviewFrame.js +14 -0
- package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
- package/dist/consent/react/ConsentSettingsPanel.js +6 -17
- package/dist/consent/react/index.d.ts +6 -1
- package/dist/consent/react/index.js +2 -0
- package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
- package/dist/consent/react/settings-panel.hooks.js +26 -0
- package/dist/core/entities/rules.js +126 -81
- package/dist/core/manifest/rules.js +10 -10
- package/dist/core/module/create-admin.js +195 -117
- package/dist/core/ports/redirects.d.ts +8 -6
- package/dist/core/ports/redirects.js +8 -6
- package/dist/core/routing/rules.js +9 -4
- package/dist/core/transport/http.js +37 -20
- package/dist/forms/adapters/memory.js +22 -11
- package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
- package/dist/forms/conformance/forms-api.conformance.js +42 -46
- package/dist/forms/field-settings.js +150 -115
- package/dist/forms/html-rules.js +45 -28
- package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
- package/dist/forms/react/components/FieldSettingsRows.js +29 -0
- package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
- package/dist/forms/react/components/FieldSettingsSection.js +5 -6
- package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
- package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
- package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
- package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
- package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
- package/dist/forms/react/components/FormSubmissions.js +4 -20
- package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
- package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
- package/dist/media/adapters/memory.js +31 -26
- package/dist/media/controllers/edit-media.controller.js +2 -1
- package/dist/media/controllers/library.controller.js +48 -35
- package/dist/media/controllers/providers.controller.js +2 -1
- package/dist/media/html-attributes.js +29 -15
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/react/components/MediaIcons.d.ts +0 -3
- package/dist/media/react/components/MediaIcons.js +0 -9
- package/dist/media/react/components/MediaLightbox.d.ts +2 -1
- package/dist/media/react/components/MediaLightbox.js +11 -5
- package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
- package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
- package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
- package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
- package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
- package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
- package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
- package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
- package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
- package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
- package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
- package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
- package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
- package/dist/media/react/index.js +4 -0
- package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
- package/dist/media/react/tabs/LibraryTab.js +2 -2
- package/dist/media/rules.d.ts +7 -0
- package/dist/media/rules.js +25 -2
- package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
- package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
- package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
- package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
- package/dist/react/components/RowMenu/RowMenu.js +16 -10
- package/dist/react/components/Sidebar.js +32 -26
- package/dist/react/entities/EntityEdit.js +17 -14
- package/dist/react/entities/EntityField.d.ts +5 -2
- package/dist/react/entities/EntityField.js +38 -23
- package/dist/react/entities/EntityList.js +6 -5
- package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
- package/dist/react/entities/RelationTruncationNotice.js +9 -0
- package/dist/react/entities/use-entity-edit.d.ts +4 -2
- package/dist/react/entities/use-entity-edit.js +52 -33
- package/dist/react/shell/AdminShell.js +40 -23
- package/dist/react/shell/model.d.ts +4 -1
- package/dist/react/shell/model.js +3 -2
- package/dist/react/shell/use-admin-shell-session.js +25 -26
- package/dist/redirects/adapters/memory.js +9 -2
- package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
- package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
- package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
- package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
- package/dist/security/adapters/http.js +25 -10
- package/dist/security/adapters/memory.js +19 -5
- package/dist/security/controllers/access-tokens.controller.js +27 -26
- package/dist/security/controllers/other-credentials.controller.js +4 -2
- package/dist/security/controllers/root-key.controller.js +17 -9
- package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
- package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
- package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
- package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
- package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
- package/dist/security/react/tabs/RootKeyTab.js +12 -3
- package/dist/security/rules.js +45 -25
- package/dist/seo/controllers/seo.controller.js +4 -2
- package/dist/seo/react/components/MediaRefField.d.ts +1 -1
- package/dist/seo/react/components/MediaRefField.js +19 -2
- package/dist/seo/react/hooks/MediaRefField.hooks.js +3 -1
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -34
- package/dist/seo/react/pages/SeoPage.d.ts +4 -4
- package/dist/seo/react/pages/SeoPage.js +26 -6
- package/dist/seo/rules.js +39 -10
- package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
- package/dist/source-control/controllers/source-control.controller.js +20 -8
- package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
- package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
- package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
- package/dist/widgets/controllers/instance-editor.controller.js +33 -24
- package/dist/widgets/controllers/library.controller.js +18 -12
- package/dist/widgets/controllers/region-editor.controller.js +6 -5
- package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
- package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
- package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
- package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
- package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
- package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
- package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
- package/dist/widgets/rules.d.ts +14 -2
- package/dist/widgets/rules.js +9 -6
- package/package.json +17 -8
- package/src/themes/default/admin.css +51 -0
- package/src/themes/default/chat.css +21 -0
- package/src/themes/default/manifest.json +29 -0
- package/dist/media/styles.d.ts +0 -4
- package/dist/media/styles.js +0 -5
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
import { AdminApiError } from '../../core/transport/errors.js';
|
|
2
|
+
// The contract excludes slug; runtime callers cannot smuggle it through a cast either.
|
|
3
|
+
const PATCHABLE_KEYS = ['name', 'fields', 'notify', 'status', 'mode', 'html'];
|
|
4
|
+
/** The allowlisted patch keys the caller actually supplied (an explicit `undefined` is not a value). */
|
|
5
|
+
function suppliedPatch(patch) {
|
|
6
|
+
return Object.fromEntries(PATCHABLE_KEYS.filter(key => patch[key] !== undefined).map(key => [key, patch[key]]));
|
|
7
|
+
}
|
|
8
|
+
/** The core contract's add/edit-only invariant: a fields replacement must keep every current id. */
|
|
9
|
+
function assertNoFieldRemoved({ current, patch }) {
|
|
10
|
+
if (!patch.fields)
|
|
11
|
+
return;
|
|
12
|
+
const incomingIds = new Set(patch.fields.map(field => field.id));
|
|
13
|
+
if (current.fields.some(field => !incomingIds.has(field.id))) {
|
|
14
|
+
throw new AdminApiError({ message: 'existing form fields cannot be removed', status: 400 }, { code: 'FORMS_FIELD_VALIDATION_ERROR' });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
2
17
|
/** A scoped, deterministic in-memory API with the core contract's add/edit-only invariant. */
|
|
3
18
|
export function createMemoryFormsApi(_required = {}, { forms = [], submissions = [], now = () => new Date(0).toISOString(), nextId } = {}) {
|
|
4
19
|
const definitions = structuredClone([...forms]);
|
|
5
20
|
const answers = structuredClone([...submissions]);
|
|
6
21
|
let sequence = definitions.length;
|
|
7
|
-
const missing = (id
|
|
22
|
+
const missing = (id, { kind = 'form definition' } = {}) => {
|
|
23
|
+
throw new AdminApiError({ message: `${kind} '${id}' was not found`, status: 404 });
|
|
24
|
+
};
|
|
8
25
|
const find = (id) => definitions.find(form => form.slug === id) ?? definitions.find(form => form.id === id) ?? missing(id);
|
|
9
26
|
const definitionId = (id) => definitions.find(form => form.slug === id)?.id ?? id;
|
|
10
27
|
const findSubmission = (formId, id) => {
|
|
11
28
|
const resolvedId = definitionId(formId);
|
|
12
|
-
return answers.find(item => item.id === id && item.formDefinitionId === resolvedId && item.status !== 'trash') ?? missing(id);
|
|
29
|
+
return answers.find(item => item.id === id && item.formDefinitionId === resolvedId && item.status !== 'trash') ?? missing(id, { kind: 'form submission' });
|
|
13
30
|
};
|
|
14
31
|
const remove = (type, id) => {
|
|
15
32
|
const rows = type === 'form' ? definitions : answers;
|
|
16
33
|
const index = rows.findIndex(row => row.id === id);
|
|
17
34
|
if (index < 0)
|
|
18
|
-
missing(id);
|
|
35
|
+
missing(id, { kind: type === 'form' ? 'form definition' : 'form submission' });
|
|
19
36
|
// The fixture retains submission payloads just as the generic Trash owner does. Definitions
|
|
20
37
|
// keep their existing separate fixture projection; the core has no definition-delete method.
|
|
21
38
|
if (type === 'form_submission')
|
|
@@ -42,14 +59,8 @@ export function createMemoryFormsApi(_required = {}, { forms = [], submissions =
|
|
|
42
59
|
},
|
|
43
60
|
async updateFormDefinition({ id }, patch = {}) {
|
|
44
61
|
const current = definitions.find(form => form.id === id) ?? missing(id);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
throw new AdminApiError({ message: 'existing form fields cannot be removed', status: 400 }, { code: 'FORMS_FIELD_VALIDATION_ERROR' });
|
|
48
|
-
}
|
|
49
|
-
// The contract excludes slug; runtime callers cannot smuggle it through a cast either.
|
|
50
|
-
const { name, fields, notify, status, mode, html } = patch;
|
|
51
|
-
const applied = { ...(name !== undefined ? { name } : {}), ...(fields !== undefined ? { fields } : {}), ...(notify !== undefined ? { notify } : {}), ...(status !== undefined ? { status } : {}), ...(mode !== undefined ? { mode } : {}), ...(html !== undefined ? { html } : {}) };
|
|
52
|
-
const updated = { ...current, ...structuredClone(applied), updatedAt: now() };
|
|
62
|
+
assertNoFieldRemoved({ current, patch });
|
|
63
|
+
const updated = { ...current, ...structuredClone(suppliedPatch(patch)), updatedAt: now() };
|
|
53
64
|
definitions[definitions.indexOf(current)] = updated;
|
|
54
65
|
return structuredClone(updated);
|
|
55
66
|
},
|
|
@@ -3,15 +3,17 @@ export interface FormsConformanceResult {
|
|
|
3
3
|
readonly passed: number;
|
|
4
4
|
readonly failures: readonly string[];
|
|
5
5
|
}
|
|
6
|
+
type SubmissionSeed = {
|
|
7
|
+
formId: string;
|
|
8
|
+
submissionId: string;
|
|
9
|
+
};
|
|
6
10
|
/** Run on an isolated API: creates definitions and trashes seeded submissions.
|
|
7
11
|
* The host provides a disposable seed so conformance never touches an operator's live data. */
|
|
8
12
|
export declare function runFormsApiConformance({ api }: {
|
|
9
13
|
api: AdminFormsPort;
|
|
10
14
|
}, { slug, submission }?: {
|
|
11
15
|
slug?: string;
|
|
12
|
-
submission?:
|
|
13
|
-
formId: string;
|
|
14
|
-
submissionId: string;
|
|
15
|
-
};
|
|
16
|
+
submission?: SubmissionSeed;
|
|
16
17
|
}): Promise<FormsConformanceResult>;
|
|
18
|
+
export {};
|
|
17
19
|
//# sourceMappingURL=forms-api.conformance.d.ts.map
|
|
@@ -7,6 +7,23 @@ export async function runFormsApiConformance({ api }, { slug = 'forms-conformanc
|
|
|
7
7
|
passed++;
|
|
8
8
|
else
|
|
9
9
|
failures.push(name); };
|
|
10
|
+
const created = await checkCreateAndRead({ api, slug, check });
|
|
11
|
+
await checkUpdates({ api, created, check });
|
|
12
|
+
if (submission)
|
|
13
|
+
await checkSubmissions({ api, created, submission, check });
|
|
14
|
+
return { passed, failures };
|
|
15
|
+
}
|
|
16
|
+
/** Resolves true when the operation is refused; only that probe's rejection is absorbed. */
|
|
17
|
+
async function rejects({ operation }) {
|
|
18
|
+
try {
|
|
19
|
+
await operation();
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function checkCreateAndRead({ api, slug, check }) {
|
|
10
27
|
const field = { id: 'email', label: 'Email', type: 'email', required: true, className: 'wide', attributes: { 'aria-label': 'Email' } };
|
|
11
28
|
const created = await api.createFormDefinition({ name: 'Contact', slug, fields: [field] }, { notify: { enabled: true, recipients: ['owner@example.com'] } });
|
|
12
29
|
check('create returns requested definition', created.name === 'Contact' && created.slug === slug && created.status === 'active');
|
|
@@ -16,56 +33,35 @@ export async function runFormsApiConformance({ api }, { slug = 'forms-conformanc
|
|
|
16
33
|
const snapshot = await api.getFormDefinition({ id: created.id });
|
|
17
34
|
Object.assign(snapshot.fields[0], { label: 'Changed only in the read snapshot' });
|
|
18
35
|
check('read snapshots cannot mutate stored fields', (await api.getFormDefinition({ id: created.id })).fields[0]?.label === 'Email');
|
|
19
|
-
|
|
36
|
+
return created;
|
|
37
|
+
}
|
|
38
|
+
async function checkUpdates({ api, created, check }) {
|
|
39
|
+
const { id } = created;
|
|
40
|
+
const updated = await api.updateFormDefinition({ id }, { name: 'Renamed', fields: [...created.fields, { id: 'message', label: 'Message', type: 'textarea', required: false }] });
|
|
20
41
|
check('name patch and added field persist', updated.name === 'Renamed' && updated.fields.length === 2);
|
|
21
42
|
check('untouched notify survives update', updated.notify.enabled && updated.notify.recipients[0] === 'owner@example.com');
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
refused = true;
|
|
28
|
-
}
|
|
29
|
-
check('existing fields cannot be removed', refused);
|
|
30
|
-
check('rejected removal leaves fields intact', (await api.getFormDefinition({ id: created.id })).fields.length === 2);
|
|
31
|
-
const disabled = await api.updateFormDefinition({ id: created.id }, { status: 'disabled' });
|
|
32
|
-
check('definition disable is reversible', disabled.status === 'disabled' && (await api.updateFormDefinition({ id: created.id }, { status: 'active' })).status === 'active');
|
|
33
|
-
const html = await api.updateFormDefinition({ id: created.id }, { mode: 'html', html: '<input name="email">' });
|
|
43
|
+
check('existing fields cannot be removed', await rejects({ operation: () => api.updateFormDefinition({ id }, { fields: [] }) }));
|
|
44
|
+
check('rejected removal leaves fields intact', (await api.getFormDefinition({ id })).fields.length === 2);
|
|
45
|
+
const disabled = await api.updateFormDefinition({ id }, { status: 'disabled' });
|
|
46
|
+
check('definition disable is reversible', disabled.status === 'disabled' && (await api.updateFormDefinition({ id }, { status: 'active' })).status === 'active');
|
|
47
|
+
const html = await api.updateFormDefinition({ id }, { mode: 'html', html: '<input name="email">' });
|
|
34
48
|
check('HTML authoring survives update', html.mode === 'html' && html.html === '<input name="email">');
|
|
35
|
-
check('builder switch is explicit', (await api.updateFormDefinition({ id
|
|
36
|
-
check('empty submission page has null cursor', (await api.listFormSubmissions({ formId:
|
|
49
|
+
check('builder switch is explicit', (await api.updateFormDefinition({ id }, { mode: 'builder' })).mode === 'builder');
|
|
50
|
+
check('empty submission page has null cursor', (await api.listFormSubmissions({ formId: id })).nextCursor === null);
|
|
37
51
|
check('definition has no delete operation', !('deleteFormDefinition' in api));
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
check('a submission of another form is unavailable', wrongFormRejected);
|
|
50
|
-
let wrongFormDeleteRejected = false;
|
|
51
|
-
try {
|
|
52
|
-
await api.deleteFormSubmission({ formId: created.id, submissionId: submission.submissionId });
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
wrongFormDeleteRejected = true;
|
|
56
|
-
}
|
|
57
|
-
check('cross-form trash is rejected without removing the submission', wrongFormDeleteRejected && (await api.getFormSubmission(submission)).id === submission.submissionId);
|
|
52
|
+
}
|
|
53
|
+
async function checkSubmissions({ api, created, submission, check }) {
|
|
54
|
+
const crossForm = { formId: created.id, submissionId: submission.submissionId };
|
|
55
|
+
const page = await api.listFormSubmissions({ formId: submission.formId }, { limit: 1 });
|
|
56
|
+
check('submission page is form scoped', page.items.every(item => item.formDefinitionId === submission.formId));
|
|
57
|
+
check('submission detail is scoped', (await api.getFormSubmission(submission)).formDefinitionId === submission.formId);
|
|
58
|
+
check('a submission of another form is unavailable', await rejects({ operation: () => api.getFormSubmission(crossForm) }));
|
|
59
|
+
const wrongFormDeleteRejected = await rejects({ operation: () => api.deleteFormSubmission(crossForm) });
|
|
60
|
+
const submissionRetained = await api.getFormSubmission(submission).then(item => item.id === submission.submissionId, () => false);
|
|
61
|
+
check('cross-form trash is rejected without removing the submission', wrongFormDeleteRejected && submissionRetained);
|
|
62
|
+
if (submissionRetained)
|
|
58
63
|
await api.deleteFormSubmission(submission);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
await api.getFormSubmission(submission);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
missing = true;
|
|
65
|
-
}
|
|
66
|
-
check('trashed submission detail is unavailable', missing);
|
|
67
|
-
check('trashed submission is absent from active pages', !(await api.listFormSubmissions({ formId: submission.formId })).items.some(item => item.id === submission.submissionId));
|
|
68
|
-
}
|
|
69
|
-
return { passed, failures };
|
|
64
|
+
check('trashed submission detail is unavailable', await rejects({ operation: () => api.getFormSubmission(submission) }));
|
|
65
|
+
check('trashed submission is absent from active pages', !(await api.listFormSubmissions({ formId: submission.formId })).items.some(item => item.id === submission.submissionId));
|
|
70
66
|
}
|
|
71
67
|
//# sourceMappingURL=forms-api.conformance.js.map
|
|
@@ -23,6 +23,20 @@ function defaultKindOf(kind) {
|
|
|
23
23
|
return "temporal";
|
|
24
24
|
return kind.control === "textarea" ? "textarea" : "text";
|
|
25
25
|
}
|
|
26
|
+
const isNumeric = ({ kind }) => kind.htmlType === "number" || kind.htmlType === "range";
|
|
27
|
+
/** Native input type for a kind's min/max; number and range share numeric bounds. */
|
|
28
|
+
function boundsOf({ kind }) {
|
|
29
|
+
if (!BOUNDS.has(kind.htmlType))
|
|
30
|
+
return null;
|
|
31
|
+
return isNumeric({ kind }) ? "number" : kind.htmlType;
|
|
32
|
+
}
|
|
33
|
+
/** Whether a (non-hidden) kind shows a placeholder. */
|
|
34
|
+
function takesPlaceholder({ kind }) {
|
|
35
|
+
// A single-choice select shows its placeholder as the empty first option.
|
|
36
|
+
if (kind.control === "select")
|
|
37
|
+
return !kind.multiple;
|
|
38
|
+
return kind.textual === true || kind.htmlType === "number";
|
|
39
|
+
}
|
|
26
40
|
/**
|
|
27
41
|
* Settings for one type, from its registry kind. Unknown (legacy or host-added) types get help
|
|
28
42
|
* text only, so the editor never offers a setting the server would refuse.
|
|
@@ -33,15 +47,13 @@ export function fieldSettingsFor({ type }, _optional = {}) {
|
|
|
33
47
|
if (!kind)
|
|
34
48
|
return NO_SETTINGS;
|
|
35
49
|
const hidden = kind.htmlType === "hidden";
|
|
36
|
-
const numeric =
|
|
37
|
-
const bounds = BOUNDS.has(kind.htmlType) ? (numeric ? "number" : kind.htmlType) : null;
|
|
50
|
+
const numeric = isNumeric({ kind });
|
|
38
51
|
return {
|
|
39
|
-
|
|
40
|
-
placeholder: !hidden && ((kind.textual === true && kind.control !== "select") || kind.htmlType === "number" || (kind.control === "select" && !kind.multiple)),
|
|
52
|
+
placeholder: !hidden && takesPlaceholder({ kind }),
|
|
41
53
|
helpText: !hidden,
|
|
42
54
|
length: kind.textual === true,
|
|
43
55
|
pattern: kind.textual === true && !hidden,
|
|
44
|
-
bounds,
|
|
56
|
+
bounds: boundsOf({ kind }),
|
|
45
57
|
step: numeric,
|
|
46
58
|
options: kind.choices === true,
|
|
47
59
|
multiple: kind.multiple === true,
|
|
@@ -96,53 +108,45 @@ const SEED_OPTIONS = [{ value: "Option 1", label: "Option 1" }, { value: "Option
|
|
|
96
108
|
export function retypeField({ field, type }, _optional = {}) {
|
|
97
109
|
const before = fieldSettingsFor({ type: field.type });
|
|
98
110
|
const after = fieldSettingsFor({ type });
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (field.maxFileSize !== undefined)
|
|
130
|
-
next.maxFileSize = field.maxFileSize;
|
|
111
|
+
return {
|
|
112
|
+
id: field.id, label: field.label, type, required: after.helpText ? field.required : false,
|
|
113
|
+
...setKeys({ field, keys: retainedKeys({ field, before, after }) }),
|
|
114
|
+
...(after.options ? { options: (field.options?.length ? field.options : SEED_OPTIONS).map((option) => ({ ...option })) } : {}),
|
|
115
|
+
...setKeys({ field, keys: after.upload ? ["accept", "maxFileSize"] : [] }),
|
|
116
|
+
// A default only survives when its shape is unchanged (text to text, one choice to one choice).
|
|
117
|
+
...(keepsDefault({ before, after }) && field.default !== undefined ? { default: field.default } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const when = (applies, keys) => (applies ? keys : []);
|
|
121
|
+
/** The settings before the options that a retype keeps, in the field's key order. */
|
|
122
|
+
function retainedKeys({ field, before, after }) {
|
|
123
|
+
return [
|
|
124
|
+
...when(after.helpText, ["helpText"]), "className", "attributes", ...when(after.placeholder, ["placeholder"]),
|
|
125
|
+
// A null max length (the wire's "cleared") is dropped rather than carried.
|
|
126
|
+
...when(after.length && field.maxLength != null, ["maxLength"]), ...when(after.length, ["minLength"]),
|
|
127
|
+
...when(after.pattern, ["pattern"]),
|
|
128
|
+
...when(after.bounds !== null && after.bounds === before.bounds, ["min", "max"]),
|
|
129
|
+
...when(after.step, ["step"]),
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
const keepsDefault = ({ before, after }) => after.defaultKind !== null && after.defaultKind === before.defaultKind && after.bounds === before.bounds;
|
|
133
|
+
/** The listed keys the field has set, in order; attribute maps and accept lists are copied, not shared. */
|
|
134
|
+
function setKeys({ field, keys }) {
|
|
135
|
+
const kept = {};
|
|
136
|
+
for (const key of keys) {
|
|
137
|
+
const value = field[key];
|
|
138
|
+
if (value === undefined)
|
|
139
|
+
continue;
|
|
140
|
+
Object.assign(kept, { [key]: Array.isArray(value) ? [...value] : typeof value === "object" && value !== null ? { ...value } : value });
|
|
131
141
|
}
|
|
132
|
-
|
|
133
|
-
if (field.default !== undefined && after.defaultKind !== null && after.defaultKind === before.defaultKind && after.bounds === before.bounds)
|
|
134
|
-
next.default = field.default;
|
|
135
|
-
return next;
|
|
142
|
+
return kept;
|
|
136
143
|
}
|
|
137
144
|
const text = (value) => (value === undefined || value === null ? "" : String(value));
|
|
138
145
|
/** Seed the draft from a stored field. @complexity O(o) in options. */
|
|
139
146
|
export function settingsFormFromField({ field }, _optional = {}) {
|
|
140
|
-
const defaults = field.default;
|
|
141
147
|
return {
|
|
142
148
|
placeholder: text(field.placeholder), helpText: text(field.helpText),
|
|
143
|
-
|
|
144
|
-
defaultChecked: defaults === true || (field.checkboxValue !== undefined && defaults === field.checkboxValue),
|
|
145
|
-
defaultChoices: Array.isArray(defaults) ? [...defaults] : typeof defaults === "string" && fieldSettingsFor({ type: field.type }).options ? [defaults] : [],
|
|
149
|
+
...defaultDraft({ field }),
|
|
146
150
|
minLength: text(field.minLength), maxLength: text(field.maxLength), pattern: text(field.pattern),
|
|
147
151
|
min: text(field.min), max: text(field.max), step: text(field.step),
|
|
148
152
|
options: (field.options ?? []).map((option, index) => ({ _rowId: index, value: option.value, label: option.label })),
|
|
@@ -150,6 +154,21 @@ export function settingsFormFromField({ field }, _optional = {}) {
|
|
|
150
154
|
maxFileSizeMb: field.maxFileSize === undefined ? "" : String(Math.round((field.maxFileSize / 1024 / 1024) * 100) / 100),
|
|
151
155
|
};
|
|
152
156
|
}
|
|
157
|
+
/** The stored default as the dialog's three default controls. */
|
|
158
|
+
function defaultDraft({ field }) {
|
|
159
|
+
const defaults = field.default;
|
|
160
|
+
return {
|
|
161
|
+
defaultText: typeof defaults === "boolean" || Array.isArray(defaults) ? "" : text(defaults),
|
|
162
|
+
defaultChecked: defaults === true || (field.checkboxValue !== undefined && defaults === field.checkboxValue),
|
|
163
|
+
defaultChoices: defaultChoicesOf({ field }),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function defaultChoicesOf({ field }) {
|
|
167
|
+
const defaults = field.default;
|
|
168
|
+
if (Array.isArray(defaults))
|
|
169
|
+
return [...defaults];
|
|
170
|
+
return typeof defaults === "string" && fieldSettingsFor({ type: field.type }).options ? [defaults] : [];
|
|
171
|
+
}
|
|
153
172
|
/**
|
|
154
173
|
* Edit one option row. A value that still mirrors its label follows label edits, so typing labels
|
|
155
174
|
* is enough for most forms; an edited value stays put.
|
|
@@ -199,59 +218,74 @@ function optionalNumber(value, error, { integer = false } = {}) {
|
|
|
199
218
|
return Number.isFinite(number) && (!integer || (Number.isInteger(number) && number >= 0)) ? { ok: true, value: number } : { ok: false, error };
|
|
200
219
|
}
|
|
201
220
|
/** Text length, pattern and placeholder settings. */
|
|
202
|
-
function textPatch(form, settings) {
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
function textPatch({ form, settings }) {
|
|
222
|
+
const length = settings.length ? lengthPatch({ form }) : { ok: true, value: {} };
|
|
223
|
+
if (!length.ok)
|
|
224
|
+
return length;
|
|
225
|
+
const pattern = settings.pattern ? patternOf({ form }) : { ok: true, value: undefined };
|
|
226
|
+
if (!pattern.ok)
|
|
227
|
+
return pattern;
|
|
228
|
+
return { ok: true, value: {
|
|
229
|
+
placeholder: settings.placeholder && !isBlank(form.placeholder) ? form.placeholder : undefined,
|
|
230
|
+
pattern: pattern.value, minLength: undefined, ...length.value,
|
|
231
|
+
} };
|
|
232
|
+
}
|
|
233
|
+
const MIN_ABOVE_MAX = { ok: false, error: "Minimum must not be above maximum." };
|
|
234
|
+
const isAbove = (min, max) => min !== undefined && max !== undefined && min > max;
|
|
235
|
+
function lengthPatch({ form }) {
|
|
236
|
+
const min = optionalNumber(form.minLength, "Length must be a whole number.", { integer: true });
|
|
237
|
+
const max = optionalNumber(form.maxLength, "Length must be a whole number.", { integer: true });
|
|
238
|
+
if (!min.ok)
|
|
239
|
+
return min;
|
|
240
|
+
if (!max.ok)
|
|
241
|
+
return max;
|
|
242
|
+
if (isAbove(min.value, max.value))
|
|
243
|
+
return MIN_ABOVE_MAX;
|
|
244
|
+
// The wire contract clears a text length with null; unsupported types omit the key entirely.
|
|
245
|
+
return { ok: true, value: { minLength: min.value, maxLength: max.value ?? null } };
|
|
246
|
+
}
|
|
247
|
+
function patternOf({ form }) {
|
|
248
|
+
if (isBlank(form.pattern))
|
|
249
|
+
return { ok: true, value: undefined };
|
|
250
|
+
// Browser-safe too: a `v`-flag-invalid pattern is silently ignored by browsers (2026-10-11).
|
|
251
|
+
if (!isBrowserSafeFieldPattern({ pattern: form.pattern }))
|
|
252
|
+
return { ok: false, error: "That pattern isn't supported." };
|
|
253
|
+
return { ok: true, value: form.pattern };
|
|
224
254
|
}
|
|
225
255
|
/** Numeric or date/time bounds, plus a numeric step. Native date/time strings compare as text. */
|
|
226
|
-
function boundsPatch(form, settings) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
256
|
+
function boundsPatch({ form, settings }) {
|
|
257
|
+
if (settings.bounds === "number")
|
|
258
|
+
return numberBoundsPatch({ form });
|
|
259
|
+
if (settings.bounds)
|
|
260
|
+
return textBoundsPatch({ form });
|
|
261
|
+
return { ok: true, value: { min: undefined, max: undefined, step: undefined } };
|
|
262
|
+
}
|
|
263
|
+
function numberBoundsPatch({ form }) {
|
|
264
|
+
const error = "Enter numbers for min, max and step.";
|
|
265
|
+
const min = optionalNumber(form.min, error);
|
|
266
|
+
const max = optionalNumber(form.max, error);
|
|
267
|
+
const step = optionalNumber(form.step, error);
|
|
268
|
+
if (!min.ok)
|
|
269
|
+
return min;
|
|
270
|
+
if (!max.ok)
|
|
271
|
+
return max;
|
|
272
|
+
if (!step.ok)
|
|
273
|
+
return step;
|
|
274
|
+
if (step.value !== undefined && step.value <= 0)
|
|
275
|
+
return { ok: false, error: "Step must be above 0." };
|
|
276
|
+
if (isAbove(min.value, max.value))
|
|
277
|
+
return MIN_ABOVE_MAX;
|
|
278
|
+
return { ok: true, value: { min: min.value, max: max.value, step: step.value } };
|
|
279
|
+
}
|
|
280
|
+
function textBoundsPatch({ form }) {
|
|
281
|
+
const min = isBlank(form.min) ? undefined : form.min;
|
|
282
|
+
const max = isBlank(form.max) ? undefined : form.max;
|
|
283
|
+
if (isAbove(min, max))
|
|
284
|
+
return MIN_ABOVE_MAX;
|
|
285
|
+
return { ok: true, value: { min, max } };
|
|
252
286
|
}
|
|
253
287
|
/** Choice options: blank rows are skipped; the rest need a label and a unique value. */
|
|
254
|
-
function optionsPatch(form, settings) {
|
|
288
|
+
function optionsPatch({ form, settings }) {
|
|
255
289
|
if (!settings.options)
|
|
256
290
|
return { ok: true, value: undefined };
|
|
257
291
|
const options = form.options.filter((row) => !(isBlank(row.value) && isBlank(row.label))).map((row) => ({ value: row.value.trim(), label: row.label.trim() }));
|
|
@@ -262,26 +296,27 @@ function optionsPatch(form, settings) {
|
|
|
262
296
|
}
|
|
263
297
|
return { ok: true, value: options };
|
|
264
298
|
}
|
|
299
|
+
const typedDefault = ({ form }) => ({ ok: true, value: isBlank(form.defaultText) ? undefined : form.defaultText });
|
|
300
|
+
const DEFAULT_READERS = {
|
|
301
|
+
// A box with an authored value submits that value, so its "checked" default is that value too.
|
|
302
|
+
boolean: ({ form, checkboxValue }) => ({ ok: true, value: form.defaultChecked ? checkboxValue ?? true : undefined }),
|
|
303
|
+
choice: ({ form, offered }) => ({ ok: true, value: form.defaultChoices.find((value) => offered.has(value)) }),
|
|
304
|
+
choices: ({ form, offered }) => {
|
|
305
|
+
const chosen = form.defaultChoices.filter((value) => offered.has(value));
|
|
306
|
+
return { ok: true, value: chosen.length ? chosen : undefined };
|
|
307
|
+
},
|
|
308
|
+
number: ({ form }) => optionalNumber(form.defaultText, "Default must be a number."),
|
|
309
|
+
text: typedDefault, textarea: typedDefault, temporal: typedDefault,
|
|
310
|
+
};
|
|
265
311
|
/** The default in the stored shape for its kind; choices drop values no longer offered. */
|
|
266
|
-
function defaultValue(form, settings, { options, checkboxValue }) {
|
|
312
|
+
function defaultValue({ form, settings }, { options, checkboxValue }) {
|
|
313
|
+
if (settings.defaultKind === null)
|
|
314
|
+
return { ok: true, value: undefined };
|
|
267
315
|
const offered = new Set((options ?? []).map((option) => option.value));
|
|
268
|
-
|
|
269
|
-
// A box with an authored value submits that value, so its "checked" default is that value too.
|
|
270
|
-
case "boolean": return { ok: true, value: form.defaultChecked ? checkboxValue ?? true : undefined };
|
|
271
|
-
case "choice": return { ok: true, value: form.defaultChoices.find((value) => offered.has(value)) };
|
|
272
|
-
case "choices": {
|
|
273
|
-
const chosen = form.defaultChoices.filter((value) => offered.has(value));
|
|
274
|
-
return { ok: true, value: chosen.length ? chosen : undefined };
|
|
275
|
-
}
|
|
276
|
-
case "number": return optionalNumber(form.defaultText, "Default must be a number.");
|
|
277
|
-
case "text":
|
|
278
|
-
case "textarea":
|
|
279
|
-
case "temporal": return { ok: true, value: isBlank(form.defaultText) ? undefined : form.defaultText };
|
|
280
|
-
default: return { ok: true, value: undefined };
|
|
281
|
-
}
|
|
316
|
+
return DEFAULT_READERS[settings.defaultKind]({ form, offered, checkboxValue });
|
|
282
317
|
}
|
|
283
318
|
/** Upload limits: no boxes ticked means every safe type; size is whole megabytes up to the cap. */
|
|
284
|
-
function uploadPatch(form, settings) {
|
|
319
|
+
function uploadPatch({ form, settings }) {
|
|
285
320
|
if (!settings.upload)
|
|
286
321
|
return { ok: true, value: {} };
|
|
287
322
|
const size = optionalNumber(form.maxFileSizeMb, "Max size must be up to 10 MB.");
|
|
@@ -302,19 +337,19 @@ function uploadPatch(form, settings) {
|
|
|
302
337
|
*/
|
|
303
338
|
export function buildFieldSettingsPatch({ form, field }, _optional = {}) {
|
|
304
339
|
const settings = fieldSettingsFor({ type: field.type });
|
|
305
|
-
const textual = textPatch(form, settings);
|
|
340
|
+
const textual = textPatch({ form, settings });
|
|
306
341
|
if (!textual.ok)
|
|
307
342
|
return textual;
|
|
308
|
-
const bounds = boundsPatch(form, settings);
|
|
343
|
+
const bounds = boundsPatch({ form, settings });
|
|
309
344
|
if (!bounds.ok)
|
|
310
345
|
return bounds;
|
|
311
|
-
const options = optionsPatch(form, settings);
|
|
346
|
+
const options = optionsPatch({ form, settings });
|
|
312
347
|
if (!options.ok)
|
|
313
348
|
return options;
|
|
314
|
-
const fallback = defaultValue(form, settings, { options: options.value, checkboxValue: field.checkboxValue });
|
|
349
|
+
const fallback = defaultValue({ form, settings }, { options: options.value, checkboxValue: field.checkboxValue });
|
|
315
350
|
if (!fallback.ok)
|
|
316
351
|
return fallback;
|
|
317
|
-
const upload = uploadPatch(form, settings);
|
|
352
|
+
const upload = uploadPatch({ form, settings });
|
|
318
353
|
if (!upload.ok)
|
|
319
354
|
return upload;
|
|
320
355
|
return { ok: true, patch: {
|