@jini-ai/admin 0.4.11 → 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 +9 -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/ports/seo.d.ts +20 -0
- 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/adapters/http.js +5 -0
- package/dist/seo/controllers/seo.controller.js +4 -2
- package/dist/seo/messages.en.d.ts +15 -10
- package/dist/seo/messages.en.js +15 -10
- package/dist/seo/models.d.ts +11 -0
- package/dist/seo/react/components/MediaRefField.d.ts +17 -5
- package/dist/seo/react/components/MediaRefField.js +31 -4
- package/dist/seo/react/hooks/MediaRefField.hooks.js +12 -2
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -0
- package/dist/seo/react/hooks/SeoPage.hooks.js +2 -2
- package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +1 -0
- package/dist/seo/react/hooks/SeoQuery.hooks.js +1 -0
- package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -2
- package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +13 -2
- package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +4 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -33
- package/dist/seo/react/index.d.ts +45 -30
- package/dist/seo/react/pages/SeoPage.d.ts +6 -4
- package/dist/seo/react/pages/SeoPage.js +51 -29
- package/dist/seo/rules.d.ts +91 -0
- package/dist/seo/rules.js +161 -6
- package/dist/seo/seo.module.d.ts +15 -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 +19 -10
- 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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from 'react';
|
|
3
|
+
import { pixelInputValue, problemsFor, useConsentSettingsPanel, usePolicyVersionInput } from './settings-panel.hooks.js';
|
|
4
|
+
const BANNER_FIELDS = [
|
|
5
|
+
{ key: 'title', label: 'Banner title' }, { key: 'message', label: 'Banner message', multiline: true },
|
|
6
|
+
{ key: 'acceptLabel', label: 'Accept button text' }, { key: 'rejectLabel', label: 'Reject button text' },
|
|
7
|
+
{ key: 'preferencesLabel', label: 'Preferences label' }, { key: 'privacyPolicyUrl', label: 'Privacy policy page URL' },
|
|
8
|
+
{ key: 'secondaryLinkLabel', label: 'Second link text' }, { key: 'secondaryLinkUrl', label: 'Second link URL' },
|
|
9
|
+
];
|
|
10
|
+
const REGION_LABELS = {
|
|
11
|
+
auto: 'Automatic (unknown region: opt-in)', 'opt-in': 'Opt-in everywhere (EU / UK)', 'opt-out': 'Opt-out everywhere (California)',
|
|
12
|
+
};
|
|
13
|
+
const PLACEMENT_LABELS = { 'bottom-bar': 'Bottom bar', 'bottom-left': 'Bottom left', 'bottom-right': 'Bottom right', 'top-bar': 'Top bar' };
|
|
14
|
+
const COLOR_TOKENS = [
|
|
15
|
+
{ key: 'backgroundColor', label: 'Background', fallback: '#ffffff' }, { key: 'textColor', label: 'Text', fallback: '#222222' }, { key: 'borderColor', label: 'Border', fallback: '#222222' },
|
|
16
|
+
];
|
|
17
|
+
/** The engine's own pixel bounds (`compileConsentCss`); out-of-range values come back as problems. */
|
|
18
|
+
const PIXEL_TOKENS = [
|
|
19
|
+
{ key: 'borderRadius', label: 'Corner radius (px)', min: 0, max: 32 }, { key: 'padding', label: 'Padding (px)', min: 8, max: 32 },
|
|
20
|
+
{ key: 'fontSize', label: 'Text size (px)', min: 16, max: 24 }, { key: 'maxWidth', label: 'Max width (px)', min: 256, max: 960 },
|
|
21
|
+
];
|
|
22
|
+
function translator(dictionary = {}) {
|
|
23
|
+
return (text) => dictionary[text] ?? text;
|
|
24
|
+
}
|
|
25
|
+
/** One field's problems, linked to its control through `aria-describedby`. */
|
|
26
|
+
function FieldProblems({ id, problems }) {
|
|
27
|
+
if (!problems.length)
|
|
28
|
+
return null;
|
|
29
|
+
return _jsx("span", { id: id, className: "jini-consent-field-problems", children: problems.map(problem => _jsx("span", { className: "jini-hint jini-hint-error", children: problem.message }, `${problem.code ?? ''}${problem.message}`)) });
|
|
30
|
+
}
|
|
31
|
+
/** Label + control + problems. Problems sit outside the label so they never join the control's
|
|
32
|
+
* name; they are its description and mark it invalid instead. */
|
|
33
|
+
function Field({ label, field, problems, children }) {
|
|
34
|
+
const id = useId();
|
|
35
|
+
const own = problemsFor({ problems, field });
|
|
36
|
+
const attributes = own.length ? { id, 'aria-invalid': true, 'aria-describedby': `${id}-problems` } : { id };
|
|
37
|
+
return _jsxs("div", { className: "jini-consent-settings-field", children: [_jsx("label", { htmlFor: id, className: "jini-field-label", children: label }), children(attributes), _jsx(FieldProblems, { id: `${id}-problems`, problems: own })] });
|
|
38
|
+
}
|
|
39
|
+
/** Banner copy and its links. Text is always plain text; the engine never renders it as HTML. */
|
|
40
|
+
export function ConsentBannerFields({ value, onChange, disabled = false, problems, dictionary, limits = {} }) {
|
|
41
|
+
const t = translator(dictionary);
|
|
42
|
+
const handlers = useConsentSettingsPanel({ value, onChange });
|
|
43
|
+
return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Banner text') }), BANNER_FIELDS.map(({ key, label, multiline }) => _jsx(Field, { label: t(label), field: key, problems: problems, children: aria => multiline
|
|
44
|
+
? _jsx("textarea", { className: "jini-input", value: value[key] ?? '', maxLength: limits[key] ?? 2000, rows: 3, onChange: handlers.text(key), ...aria })
|
|
45
|
+
: _jsx("input", { className: "jini-input", type: "text", value: value[key] ?? '', maxLength: limits[key] ?? 2000, onChange: handlers.text(key), ...aria }) }, key)), _jsx("p", { className: "jini-hint", children: t('Link a published privacy page, such as /privacy. The second link is optional.') })] });
|
|
46
|
+
}
|
|
47
|
+
/** On/off, region behavior and the policy version that re-asks visitors when it changes. */
|
|
48
|
+
export function ConsentRulesFields({ value, onChange, disabled = false, problems, dictionary, limits = {}, regionModes = ['auto', 'opt-in', 'opt-out'] }) {
|
|
49
|
+
const id = useId();
|
|
50
|
+
const t = translator(dictionary);
|
|
51
|
+
const handlers = useConsentSettingsPanel({ value, onChange });
|
|
52
|
+
const modes = regionModes.includes(value.regionMode) ? regionModes : [value.regionMode, ...regionModes];
|
|
53
|
+
return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Rules') }), _jsxs("button", { type: "button", className: value.enabled ? 'jini-toggle-row on' : 'jini-toggle-row', role: "switch", "aria-checked": value.enabled, "aria-labelledby": `${id}-enabled`, "aria-describedby": `${id}-hint`, onClick: handlers.toggle, children: [_jsxs("span", { className: "jini-toggle-row-text", children: [_jsx("span", { id: `${id}-enabled`, className: "jini-toggle-row-label", children: t('Enable cookie consent on this site') }), _jsx("span", { id: `${id}-hint`, className: "jini-toggle-row-hint", children: t('Applies to published site pages.') })] }), _jsx("span", { className: "jini-toggle-row-switch", "aria-hidden": "true" })] }), _jsx(Field, { label: t('Region mode'), field: "regionMode", problems: problems, children: aria => _jsx("select", { className: "jini-input", value: value.regionMode, onChange: handlers.region, ...aria, children: modes.map(mode => _jsx("option", { value: mode, children: t(REGION_LABELS[mode]) }, mode)) }) }), _jsx("p", { className: "jini-hint", children: t('California permits optional categories until opt-out. Global Privacy Control always disables analytics and marketing.') }), _jsx(Field, { label: t('Policy version'), field: "policyVersion", problems: problems, children: aria => _jsx(PolicyVersionInput, { value: value.policyVersion, onChange: onChange, maxLength: limits.policyVersion ?? 128, aria: aria }) }), _jsx("p", { className: "jini-hint", children: t('Change the policy version to ask visitors to choose again.') })] });
|
|
54
|
+
}
|
|
55
|
+
/** Placement, bounded appearance tokens and allowlisted custom CSS. The engine checks contrast and
|
|
56
|
+
* CSS safety; its problems arrive on the `appearance` field and show under the custom CSS. */
|
|
57
|
+
export function ConsentLookFields({ value, onChange, disabled = false, problems, dictionary, placements = Object.keys(PLACEMENT_LABELS) }) {
|
|
58
|
+
const t = translator(dictionary);
|
|
59
|
+
const handlers = useConsentSettingsPanel({ value, onChange });
|
|
60
|
+
const appearance = value.appearance ?? {};
|
|
61
|
+
return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Look & position') }), _jsx(Field, { label: t('Position'), field: "placement", problems: problems, children: aria => _jsx("select", { className: "jini-input", value: value.placement ?? placements[0], onChange: handlers.placement, ...aria, children: placements.map(placement => _jsx("option", { value: placement, children: t(PLACEMENT_LABELS[placement] ?? placement) }, placement)) }) }), _jsxs("div", { className: "jini-consent-token-grid", children: [COLOR_TOKENS.map(token => _jsx(ColorToken, { label: t(token.label), value: appearance[token.key], fallback: token.fallback, resetLabel: t('Default'), onChange: handlers.token(token.key) }, token.key)), PIXEL_TOKENS.map(token => _jsx(Field, { label: t(token.label), field: token.key, children: attributes => _jsx("input", { className: "jini-input", type: "number", inputMode: "numeric", min: token.min, max: token.max, step: 1, placeholder: t('Default'), value: pixelInputValue({ value: appearance[token.key] }), onChange: event => handlers.token(token.key)(event.target.value === '' ? '' : `${event.target.value}px`), ...attributes }) }, token.key))] }), _jsx(Field, { label: t('Custom CSS'), field: "appearance", problems: problems, children: aria => _jsx("textarea", { className: "jini-input jini-consent-css", value: value.customCss ?? '', rows: 6, spellCheck: false, onChange: handlers.text('customCss'), ...aria }) }), _jsx("p", { className: "jini-hint", children: t('Only banner selectors and safe properties are allowed. Text needs 4.5:1 contrast.') })] });
|
|
62
|
+
}
|
|
63
|
+
/** A native color picker; unset shows the engine default and offers no reset. */
|
|
64
|
+
function ColorToken({ label, value, fallback, resetLabel, onChange }) {
|
|
65
|
+
const id = useId();
|
|
66
|
+
return _jsxs("div", { className: "jini-consent-settings-field", children: [_jsx("label", { htmlFor: id, className: "jini-field-label", children: label }), _jsxs("span", { className: "jini-consent-color-row", children: [_jsx("input", { id: id, className: "jini-consent-color", type: "color", value: value ?? fallback, onChange: event => onChange(event.target.value) }), _jsx("code", { className: "jini-hint", children: value ?? resetLabel }), value !== undefined && _jsx("button", { type: "button", className: "jini-btn-link", "aria-label": `${resetLabel}: ${label}`, onClick: () => onChange(''), children: resetLabel })] })] });
|
|
67
|
+
}
|
|
68
|
+
/** Keep transient empty edits local; only a valid policy version reaches autosave.
|
|
69
|
+
* Native validity gives keyboard users an actionable error. @complexity O(version length), <=128. */
|
|
70
|
+
function PolicyVersionInput({ value, onChange, maxLength, aria }) {
|
|
71
|
+
const { draft, change, blur } = usePolicyVersionInput({ value, onChange });
|
|
72
|
+
return _jsx("input", { className: "jini-input", type: "text", value: draft, required: true, pattern: String.raw `.*\S.*`, maxLength: maxLength, onChange: change, onBlur: blur, ...aria });
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=ConsentFields.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ConsentPreviewWidth } from './settings-panel.hooks.js';
|
|
2
|
+
export interface ConsentPreviewFrameProps {
|
|
3
|
+
/** The engine-gated page; `null` while it loads or when the host could not render it. */
|
|
4
|
+
html: string | null;
|
|
5
|
+
/** Shown in place of the frame while `html` is null. */
|
|
6
|
+
status?: string;
|
|
7
|
+
initialWidth?: ConsentPreviewWidth;
|
|
8
|
+
dictionary?: Readonly<Record<string, string>>;
|
|
9
|
+
}
|
|
10
|
+
/** The real rendered page with the banner, inert. The HTML is only ever the `srcdoc` of an iframe
|
|
11
|
+
* with an empty `sandbox`: no scripts, forms, popups or same-origin access, and it never enters the
|
|
12
|
+
* host DOM. Phone is 320px, the narrowest width the banner must still work at. */
|
|
13
|
+
export declare function ConsentPreviewFrame({ html, status, initialWidth, dictionary }: ConsentPreviewFrameProps): import("react").JSX.Element;
|
|
14
|
+
//# sourceMappingURL=ConsentPreviewFrame.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useConsentPreviewWidth } from './settings-panel.hooks.js';
|
|
3
|
+
const WIDTHS = [{ id: 'phone', label: 'Phone' }, { id: 'desktop', label: 'Desktop' }];
|
|
4
|
+
/** The real rendered page with the banner, inert. The HTML is only ever the `srcdoc` of an iframe
|
|
5
|
+
* with an empty `sandbox`: no scripts, forms, popups or same-origin access, and it never enters the
|
|
6
|
+
* host DOM. Phone is 320px, the narrowest width the banner must still work at. */
|
|
7
|
+
export function ConsentPreviewFrame({ html, status = '', initialWidth = 'desktop', dictionary = {} }) {
|
|
8
|
+
const t = (text) => dictionary[text] ?? text;
|
|
9
|
+
const { width, setWidth } = useConsentPreviewWidth({ initial: initialWidth });
|
|
10
|
+
return _jsxs("section", { className: "jini-consent-preview", "aria-label": t('Banner preview'), children: [_jsx("div", { className: "jini-consent-preview-widths", role: "group", "aria-label": t('Preview width'), children: WIDTHS.map(option => _jsx("button", { type: "button", className: "jini-btn-secondary", "aria-pressed": width === option.id, onClick: () => setWidth(option.id), children: t(option.label) }, option.id)) }), html === null
|
|
11
|
+
? _jsx("p", { className: "jini-hint jini-consent-preview-empty", children: status })
|
|
12
|
+
: _jsx("iframe", { className: `jini-consent-preview-frame is-${width}`, title: t('Banner preview'), sandbox: "", srcDoc: html, style: { width: width === 'phone' ? '320px' : '100%' } })] });
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=ConsentPreviewFrame.js.map
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
import { type ConsentFieldsProps } from './ConsentFields.js';
|
|
2
|
+
/** Bounded appearance tokens, as the engine accepts them: opaque hex colors and `Npx` lengths. */
|
|
3
|
+
export interface ConsentAppearanceView {
|
|
4
|
+
backgroundColor?: string;
|
|
5
|
+
textColor?: string;
|
|
6
|
+
borderColor?: string;
|
|
7
|
+
borderRadius?: string;
|
|
8
|
+
padding?: string;
|
|
9
|
+
fontSize?: string;
|
|
10
|
+
maxWidth?: string;
|
|
11
|
+
}
|
|
12
|
+
/** The host's consent configuration. The look fields are optional so older hosts stay valid. */
|
|
2
13
|
export interface ConsentSettingsView {
|
|
3
14
|
enabled: boolean;
|
|
4
15
|
regionMode: 'auto' | 'opt-in' | 'opt-out';
|
|
@@ -9,8 +20,19 @@ export interface ConsentSettingsView {
|
|
|
9
20
|
acceptLabel: string;
|
|
10
21
|
rejectLabel: string;
|
|
11
22
|
preferencesLabel: string;
|
|
23
|
+
placement?: string;
|
|
24
|
+
secondaryLinkLabel?: string;
|
|
25
|
+
secondaryLinkUrl?: string;
|
|
26
|
+
appearance?: ConsentAppearanceView;
|
|
27
|
+
customCss?: string;
|
|
28
|
+
}
|
|
29
|
+
/** One validation result. `field` names the setting it belongs to; field-less problems are the host's to show. */
|
|
30
|
+
export interface ConsentFieldProblem {
|
|
31
|
+
message: string;
|
|
32
|
+
field?: string;
|
|
33
|
+
code?: string;
|
|
12
34
|
}
|
|
13
|
-
export interface ConsentSettingsPanelProps {
|
|
35
|
+
export interface ConsentSettingsPanelProps extends Pick<ConsentFieldsProps, 'problems' | 'limits'> {
|
|
14
36
|
value: ConsentSettingsView;
|
|
15
37
|
onChange(required: {
|
|
16
38
|
patch: Partial<ConsentSettingsView>;
|
|
@@ -21,7 +43,8 @@ export interface ConsentSettingsPanelProps {
|
|
|
21
43
|
/** Host translations keyed by the exact fallback copy; unspecified entries use English. */
|
|
22
44
|
dictionary?: Readonly<Record<string, string>>;
|
|
23
45
|
}
|
|
24
|
-
/**
|
|
46
|
+
/** The composed default for hosts that want one screen: Rules, Banner and Look stacked.
|
|
47
|
+
* Shared jini settings classes keep the panel in the host's design language.
|
|
25
48
|
* Native labels/selects/checkboxes support keyboard use at desktop and narrow widths. */
|
|
26
|
-
export declare function ConsentSettingsPanel({ value, onChange, loading, error, status, dictionary }: ConsentSettingsPanelProps): import("react").JSX.Element;
|
|
49
|
+
export declare function ConsentSettingsPanel({ value, onChange, loading, error, status, dictionary, problems, limits }: ConsentSettingsPanelProps): import("react").JSX.Element;
|
|
27
50
|
//# sourceMappingURL=ConsentSettingsPanel.d.ts.map
|
|
@@ -1,24 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useId } from 'react';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
['acceptLabel', 'Accept button text', 2000], ['rejectLabel', 'Reject button text', 2000],
|
|
7
|
-
['preferencesLabel', 'Preferences label', 2000], ['policyVersion', 'Policy version', 128],
|
|
8
|
-
['privacyPolicyUrl', 'Privacy policy page URL', 2000],
|
|
9
|
-
];
|
|
10
|
-
/** Shared jini settings classes keep the panel in the host's design language.
|
|
3
|
+
import { ConsentBannerFields, ConsentLookFields, ConsentRulesFields } from './ConsentFields.js';
|
|
4
|
+
/** The composed default for hosts that want one screen: Rules, Banner and Look stacked.
|
|
5
|
+
* Shared jini settings classes keep the panel in the host's design language.
|
|
11
6
|
* Native labels/selects/checkboxes support keyboard use at desktop and narrow widths. */
|
|
12
|
-
export function ConsentSettingsPanel({ value, onChange, loading, error, status, dictionary = {} }) {
|
|
7
|
+
export function ConsentSettingsPanel({ value, onChange, loading, error, status, dictionary = {}, problems, limits }) {
|
|
13
8
|
const id = useId();
|
|
14
9
|
const t = (text) => dictionary[text] ?? text;
|
|
15
|
-
const
|
|
16
|
-
return _jsxs("section", { className: "jini-settings-section", "aria-labelledby": `${id}-heading`, children: [_jsx("div", { className: "jini-section-head", children: _jsxs("div", { children: [_jsx("h4", { id: `${id}-heading`, children: t('Site cookie consent') }), _jsx("p", { className: "jini-hint", children: t('Provides the consent mechanism, not legal advice. Review your texts, categories, and privacy policy for your site.') })] }) }), _jsx("p", { className: "jini-hint", children: t('Off by default. In opt-in regions, optional scripts stay paused until a visitor chooses. Unclassified third-party scripts use the marketing category. Global Privacy Control disables analytics and marketing.') }),
|
|
17
|
-
}
|
|
18
|
-
/** Keep transient empty edits local; only a valid policy version reaches autosave.
|
|
19
|
-
* Native validity gives keyboard users an actionable error. @complexity O(version length), <=128. */
|
|
20
|
-
function PolicyVersionInput({ value, onChange }) {
|
|
21
|
-
const { draft, change, blur } = usePolicyVersionInput({ value, onChange });
|
|
22
|
-
return _jsx("input", { className: "jini-input", type: "text", value: draft, required: true, pattern: String.raw `.*\S.*`, maxLength: 128, onChange: change, onBlur: blur });
|
|
10
|
+
const fields = { value, onChange, disabled: loading, problems, dictionary, limits };
|
|
11
|
+
return _jsxs("section", { className: "jini-settings-section", "aria-labelledby": `${id}-heading`, children: [_jsx("div", { className: "jini-section-head", children: _jsxs("div", { children: [_jsx("h4", { id: `${id}-heading`, children: t('Site cookie consent') }), _jsx("p", { className: "jini-hint", children: t('Provides the consent mechanism, not legal advice. Review your texts, categories, and privacy policy for your site.') })] }) }), _jsx("p", { className: "jini-hint", children: t('Off by default. In opt-in regions, optional scripts stay paused until a visitor chooses. Unclassified third-party scripts use the marketing category. Global Privacy Control disables analytics and marketing.') }), _jsx(ConsentRulesFields, { ...fields }), _jsx(ConsentBannerFields, { ...fields }), _jsx(ConsentLookFields, { ...fields }), error && _jsx("p", { className: "jini-hint jini-hint-error", role: "alert", children: error }), _jsx("p", { className: "jini-hint", role: "status", children: status })] });
|
|
23
12
|
}
|
|
24
13
|
//# sourceMappingURL=ConsentSettingsPanel.js.map
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
export { ConsentSettingsPanel } from './ConsentSettingsPanel.js';
|
|
2
|
-
export type { ConsentSettingsPanelProps, ConsentSettingsView } from './ConsentSettingsPanel.js';
|
|
2
|
+
export type { ConsentAppearanceView, ConsentFieldProblem, ConsentSettingsPanelProps, ConsentSettingsView } from './ConsentSettingsPanel.js';
|
|
3
|
+
export { ConsentBannerFields, ConsentLookFields, ConsentRulesFields } from './ConsentFields.js';
|
|
4
|
+
export type { ConsentFieldsProps } from './ConsentFields.js';
|
|
5
|
+
export { ConsentPreviewFrame } from './ConsentPreviewFrame.js';
|
|
6
|
+
export type { ConsentPreviewFrameProps } from './ConsentPreviewFrame.js';
|
|
7
|
+
export type { ConsentPreviewWidth } from './settings-panel.hooks.js';
|
|
3
8
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import { type ChangeEvent, type FocusEvent } from 'react';
|
|
2
|
-
import type { ConsentSettingsPanelProps, ConsentSettingsView } from './ConsentSettingsPanel.js';
|
|
2
|
+
import type { ConsentAppearanceView, ConsentFieldProblem, ConsentSettingsPanelProps, ConsentSettingsView } from './ConsentSettingsPanel.js';
|
|
3
3
|
/** Keep value conversions and toggle behavior out of the view. React owns event signatures. */
|
|
4
4
|
export declare function useConsentSettingsPanel({ value, onChange }: Pick<ConsentSettingsPanelProps, 'value' | 'onChange'>, _options?: {}): {
|
|
5
5
|
toggle: () => void;
|
|
6
6
|
region: (event: ChangeEvent<HTMLSelectElement>) => void;
|
|
7
|
+
placement: (event: ChangeEvent<HTMLSelectElement>) => void;
|
|
7
8
|
text: (key: keyof ConsentSettingsView) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
|
9
|
+
/** Empty input unsets the token so the engine default applies again. */
|
|
10
|
+
token: (key: keyof ConsentAppearanceView) => (next: string) => void;
|
|
8
11
|
};
|
|
12
|
+
/** A new token object with `key` set, or removed when `value` is empty. Never mutates the input.
|
|
13
|
+
* @complexity O(t), t <= 7 tokens. */
|
|
14
|
+
export declare function withAppearanceToken({ appearance, key, value }: {
|
|
15
|
+
appearance?: ConsentAppearanceView | undefined;
|
|
16
|
+
key: keyof ConsentAppearanceView;
|
|
17
|
+
value: string;
|
|
18
|
+
}, _options?: {}): ConsentAppearanceView;
|
|
19
|
+
/** `"12px"` → `"12"` for a number input; unset or hand-written values read empty. */
|
|
20
|
+
export declare function pixelInputValue({ value }: {
|
|
21
|
+
value: string | undefined;
|
|
22
|
+
}, _options?: {}): string;
|
|
23
|
+
/** The problems a host attached to one field, in their original order. O(p). */
|
|
24
|
+
export declare function problemsFor({ problems, field }: {
|
|
25
|
+
problems?: readonly ConsentFieldProblem[] | undefined;
|
|
26
|
+
field: string;
|
|
27
|
+
}, _options?: {}): ConsentFieldProblem[];
|
|
9
28
|
/** Keep transient empty edits local; only a valid policy version reaches autosave.
|
|
10
29
|
* Native validity gives keyboard users an actionable error. Valid edits save before navigation.
|
|
11
30
|
* @complexity O(version length), <=128. */
|
|
@@ -16,4 +35,12 @@ export declare function usePolicyVersionInput({ value, onChange }: Pick<ConsentS
|
|
|
16
35
|
change: (event: ChangeEvent<HTMLInputElement>) => void;
|
|
17
36
|
blur: (event: FocusEvent<HTMLInputElement>) => void;
|
|
18
37
|
};
|
|
38
|
+
export type ConsentPreviewWidth = 'phone' | 'desktop';
|
|
39
|
+
/** The preview's own phone/desktop switch; the host never needs to own it. */
|
|
40
|
+
export declare function useConsentPreviewWidth({ initial }?: {
|
|
41
|
+
initial?: ConsentPreviewWidth;
|
|
42
|
+
}, _options?: {}): {
|
|
43
|
+
width: ConsentPreviewWidth;
|
|
44
|
+
setWidth: import("react").Dispatch<import("react").SetStateAction<ConsentPreviewWidth>>;
|
|
45
|
+
};
|
|
19
46
|
//# sourceMappingURL=settings-panel.hooks.d.ts.map
|
|
@@ -4,9 +4,30 @@ export function useConsentSettingsPanel({ value, onChange }, _options = {}) {
|
|
|
4
4
|
return {
|
|
5
5
|
toggle: () => onChange({ patch: { enabled: !value.enabled } }),
|
|
6
6
|
region: (event) => onChange({ patch: { regionMode: event.target.value } }),
|
|
7
|
+
placement: (event) => onChange({ patch: { placement: event.target.value } }),
|
|
7
8
|
text: (key) => (event) => onChange({ patch: { [key]: event.target.value } }),
|
|
9
|
+
/** Empty input unsets the token so the engine default applies again. */
|
|
10
|
+
token: (key) => (next) => onChange({ patch: { appearance: withAppearanceToken({ appearance: value.appearance, key, value: next }) } }),
|
|
8
11
|
};
|
|
9
12
|
}
|
|
13
|
+
/** A new token object with `key` set, or removed when `value` is empty. Never mutates the input.
|
|
14
|
+
* @complexity O(t), t <= 7 tokens. */
|
|
15
|
+
export function withAppearanceToken({ appearance = {}, key, value }, _options = {}) {
|
|
16
|
+
const next = { ...appearance };
|
|
17
|
+
if (value === '')
|
|
18
|
+
delete next[key];
|
|
19
|
+
else
|
|
20
|
+
next[key] = value;
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
23
|
+
/** `"12px"` → `"12"` for a number input; unset or hand-written values read empty. */
|
|
24
|
+
export function pixelInputValue({ value }, _options = {}) {
|
|
25
|
+
return /^(\d+(?:\.\d+)?)px$/.exec(value ?? '')?.[1] ?? '';
|
|
26
|
+
}
|
|
27
|
+
/** The problems a host attached to one field, in their original order. O(p). */
|
|
28
|
+
export function problemsFor({ problems = [], field }, _options = {}) {
|
|
29
|
+
return problems.filter(problem => problem.field === field);
|
|
30
|
+
}
|
|
10
31
|
/** Keep transient empty edits local; only a valid policy version reaches autosave.
|
|
11
32
|
* Native validity gives keyboard users an actionable error. Valid edits save before navigation.
|
|
12
33
|
* @complexity O(version length), <=128. */
|
|
@@ -27,4 +48,9 @@ export function usePolicyVersionInput({ value, onChange }, _options = {}) {
|
|
|
27
48
|
},
|
|
28
49
|
};
|
|
29
50
|
}
|
|
51
|
+
/** The preview's own phone/desktop switch; the host never needs to own it. */
|
|
52
|
+
export function useConsentPreviewWidth({ initial = 'desktop' } = {}, _options = {}) {
|
|
53
|
+
const [width, setWidth] = useState(initial);
|
|
54
|
+
return { width, setWidth };
|
|
55
|
+
}
|
|
30
56
|
//# sourceMappingURL=settings-panel.hooks.js.map
|
|
@@ -42,40 +42,59 @@ const MAX_JSON_NODES = 10_000;
|
|
|
42
42
|
* {@link MAX_JSON_DEPTH}.
|
|
43
43
|
* @overallScore 100
|
|
44
44
|
*/
|
|
45
|
-
function describeNonJsonValue(value, depth
|
|
45
|
+
function describeNonJsonValue({ value, depth, budget }, _optional = {}) {
|
|
46
46
|
if (depth > MAX_JSON_DEPTH)
|
|
47
47
|
return `nests deeper than ${MAX_JSON_DEPTH} levels`;
|
|
48
48
|
if (++budget.nodes > MAX_JSON_NODES)
|
|
49
49
|
return `holds more than ${MAX_JSON_NODES} values`;
|
|
50
|
-
if (value === null)
|
|
51
|
-
return
|
|
52
|
-
const type = typeof value;
|
|
53
|
-
if (type === 'string' || type === 'boolean')
|
|
54
|
-
return null;
|
|
55
|
-
if (type === 'number')
|
|
56
|
-
return Number.isFinite(value) ? null : 'is a non-finite number';
|
|
57
|
-
if (type !== 'object')
|
|
58
|
-
return `is a ${type}`;
|
|
59
|
-
if (Array.isArray(value)) {
|
|
60
|
-
for (const item of value) {
|
|
61
|
-
const reason = describeNonJsonValue(item, depth + 1, budget);
|
|
62
|
-
if (reason !== null)
|
|
63
|
-
return reason;
|
|
64
|
-
}
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
50
|
+
if (value === null || typeof value !== 'object')
|
|
51
|
+
return describeNonJsonScalar(value);
|
|
67
52
|
// Rejects class instances, `Map`, `Date`, and anything else that survives `typeof === 'object'`
|
|
68
53
|
// but does not round-trip through JSON as itself.
|
|
69
|
-
|
|
70
|
-
if (proto !== Object.prototype && proto !== null)
|
|
54
|
+
if (!Array.isArray(value) && !isPlainObject(value))
|
|
71
55
|
return 'is a non-plain object';
|
|
72
56
|
for (const item of Object.values(value)) {
|
|
73
|
-
const reason = describeNonJsonValue(item, depth + 1, budget);
|
|
57
|
+
const reason = describeNonJsonValue({ value: item, depth: depth + 1, budget });
|
|
74
58
|
if (reason !== null)
|
|
75
59
|
return reason;
|
|
76
60
|
}
|
|
77
61
|
return null;
|
|
78
62
|
}
|
|
63
|
+
/** The leaf half of {@link describeNonJsonValue}: `null` or a non-object `value`. */
|
|
64
|
+
function describeNonJsonScalar(value) {
|
|
65
|
+
if (value === null)
|
|
66
|
+
return null;
|
|
67
|
+
const type = typeof value;
|
|
68
|
+
if (type === 'string' || type === 'boolean')
|
|
69
|
+
return null;
|
|
70
|
+
if (type === 'number')
|
|
71
|
+
return Number.isFinite(value) ? null : 'is a non-finite number';
|
|
72
|
+
return `is a ${type}`;
|
|
73
|
+
}
|
|
74
|
+
function isPlainObject(value) {
|
|
75
|
+
const proto = Object.getPrototypeOf(value);
|
|
76
|
+
return proto === Object.prototype || proto === null;
|
|
77
|
+
}
|
|
78
|
+
/** `null` when `typeof value` is `type`, otherwise "expected <label>, got <typeof value>". */
|
|
79
|
+
function describeWrongType({ value, type, label }, _optional = {}) {
|
|
80
|
+
return typeof value === type ? null : `expected ${label}, got ${typeof value}`;
|
|
81
|
+
}
|
|
82
|
+
function describeNonDatetime(value) {
|
|
83
|
+
if (typeof value !== 'string')
|
|
84
|
+
return `expected an ISO-8601 string, got ${typeof value}`;
|
|
85
|
+
// A `datetime` that is not parseable renders fine and orders wrongly — the exact class of
|
|
86
|
+
// fault this whole check exists for, so it is a mismatch rather than a pass.
|
|
87
|
+
return Number.isNaN(Date.parse(value)) ? 'is not a parseable ISO-8601 timestamp' : null;
|
|
88
|
+
}
|
|
89
|
+
function describeNonNumber({ value, accepts, reason }, _optional = {}) {
|
|
90
|
+
if (typeof value !== 'number')
|
|
91
|
+
return `expected a number, got ${typeof value}`;
|
|
92
|
+
return accepts(value) ? null : reason;
|
|
93
|
+
}
|
|
94
|
+
function describeNonJson(value) {
|
|
95
|
+
const reason = describeNonJsonValue({ value, depth: 0, budget: { nodes: 0 } });
|
|
96
|
+
return reason === null ? null : `is not a JSON value: it ${reason}`;
|
|
97
|
+
}
|
|
79
98
|
/**
|
|
80
99
|
* Whether `value` is a legal runtime value for `field`.
|
|
81
100
|
*
|
|
@@ -98,27 +117,21 @@ export function describeAdminEntityFieldMismatch(field, value) {
|
|
|
98
117
|
switch (field.kind) {
|
|
99
118
|
case 'text':
|
|
100
119
|
case 'relation':
|
|
101
|
-
return
|
|
120
|
+
return describeWrongType({ value, type: 'string', label: 'a string' });
|
|
102
121
|
case 'datetime':
|
|
103
|
-
|
|
104
|
-
return `expected an ISO-8601 string, got ${typeof value}`;
|
|
105
|
-
// A `datetime` that is not parseable renders fine and orders wrongly — the exact class of
|
|
106
|
-
// fault this whole check exists for, so it is a mismatch rather than a pass.
|
|
107
|
-
return Number.isNaN(Date.parse(value)) ? 'is not a parseable ISO-8601 timestamp' : null;
|
|
122
|
+
return describeNonDatetime(value);
|
|
108
123
|
case 'integer':
|
|
109
|
-
|
|
110
|
-
return `expected a number, got ${typeof value}`;
|
|
111
|
-
return Number.isInteger(value) ? null : 'is not an integer';
|
|
124
|
+
return describeNonNumber({ value, accepts: Number.isInteger, reason: 'is not an integer' });
|
|
112
125
|
case 'real':
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
return describeNonNumber({
|
|
127
|
+
value,
|
|
128
|
+
accepts: Number.isFinite,
|
|
129
|
+
reason: 'is not a finite number',
|
|
130
|
+
});
|
|
116
131
|
case 'boolean':
|
|
117
|
-
return
|
|
118
|
-
case 'json':
|
|
119
|
-
|
|
120
|
-
return reason === null ? null : `is not a JSON value: it ${reason}`;
|
|
121
|
-
}
|
|
132
|
+
return describeWrongType({ value, type: 'boolean', label: 'a boolean' });
|
|
133
|
+
case 'json':
|
|
134
|
+
return describeNonJson(value);
|
|
122
135
|
}
|
|
123
136
|
}
|
|
124
137
|
/**
|
|
@@ -132,49 +145,63 @@ export function describeAdminEntityFieldMismatch(field, value) {
|
|
|
132
145
|
* @complexity Time O(fields + row keys) plus the `json` walk; space O(violations).
|
|
133
146
|
* @overallScore 100
|
|
134
147
|
*/
|
|
135
|
-
function findRowViolations(descriptor, operation, row) {
|
|
148
|
+
function findRowViolations({ descriptor, operation, row, }, _optional = {}) {
|
|
149
|
+
const hasId = typeof row.id === 'string' && row.id !== '';
|
|
150
|
+
const rowId = hasId ? row.id : '<no id>';
|
|
151
|
+
const at = { entity: descriptor.name, operation, rowId };
|
|
136
152
|
const found = [];
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
153
|
+
if (!hasId) {
|
|
154
|
+
found.push({
|
|
155
|
+
...at,
|
|
156
|
+
field: IMPLICIT_ROW_KEY,
|
|
157
|
+
problem: 'wrong-type',
|
|
158
|
+
detail: 'every row must carry a non-empty string `id`',
|
|
159
|
+
});
|
|
143
160
|
}
|
|
144
|
-
const declared = new Set([IMPLICIT_ROW_KEY]);
|
|
145
161
|
for (const field of descriptor.fields) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (field.required === true) {
|
|
150
|
-
push(field.name, 'missing-required', 'declared `required: true` but absent from the row');
|
|
151
|
-
}
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
const mismatch = describeAdminEntityFieldMismatch(field, value);
|
|
155
|
-
if (mismatch !== null)
|
|
156
|
-
push(field.name, 'wrong-type', `declared \`${field.kind}\`; ${mismatch}`);
|
|
162
|
+
const problem = describeFieldProblem({ field, value: row[field.name] });
|
|
163
|
+
if (problem !== null)
|
|
164
|
+
found.push({ ...at, field: field.name, ...problem });
|
|
157
165
|
}
|
|
166
|
+
const declared = new Set([IMPLICIT_ROW_KEY, ...descriptor.fields.map((field) => field.name)]);
|
|
158
167
|
for (const key of Object.keys(row)) {
|
|
159
168
|
if (declared.has(key))
|
|
160
169
|
continue;
|
|
161
|
-
push(
|
|
170
|
+
found.push({
|
|
171
|
+
...at,
|
|
172
|
+
field: key,
|
|
173
|
+
problem: 'undeclared-field',
|
|
174
|
+
detail: 'present on the row but not declared on the descriptor',
|
|
175
|
+
});
|
|
162
176
|
}
|
|
163
177
|
return found;
|
|
164
178
|
}
|
|
179
|
+
/** What is wrong with one declared field's cell, or `null` when it conforms or is legally absent. */
|
|
180
|
+
function describeFieldProblem({ field, value }, _optional = {}) {
|
|
181
|
+
if (value === undefined) {
|
|
182
|
+
return field.required === true
|
|
183
|
+
? { problem: 'missing-required', detail: 'declared `required: true` but absent from the row' }
|
|
184
|
+
: null;
|
|
185
|
+
}
|
|
186
|
+
const mismatch = describeAdminEntityFieldMismatch(field, value);
|
|
187
|
+
return mismatch === null
|
|
188
|
+
? null
|
|
189
|
+
: { problem: 'wrong-type', detail: `declared \`${field.kind}\`; ${mismatch}` };
|
|
190
|
+
}
|
|
165
191
|
/**
|
|
166
192
|
* Runs `row` past its descriptor and reports anything wrong, then returns it unchanged.
|
|
167
193
|
*
|
|
168
194
|
* Deliberately identity-returning: this is an observation point, never a repair point. Coercing a
|
|
169
195
|
* bad value here would hide the adapter bug it exists to expose.
|
|
170
196
|
*
|
|
171
|
-
* @
|
|
197
|
+
* Only {@link eraseEntityPort}'s checking wrapper calls this, and that wrapper exists only when
|
|
198
|
+
* `onViolation` is set — so the sink is required here rather than re-checked.
|
|
199
|
+
*
|
|
200
|
+
* @complexity Time O(rows × fields).
|
|
172
201
|
* @overallScore 100
|
|
173
202
|
*/
|
|
174
|
-
function checkRow(descriptor, operation, row, onViolation) {
|
|
175
|
-
|
|
176
|
-
return row;
|
|
177
|
-
for (const violation of findRowViolations(descriptor, operation, row))
|
|
203
|
+
function checkRow({ descriptor, operation, row, onViolation, }, _optional = {}) {
|
|
204
|
+
for (const violation of findRowViolations({ descriptor, operation, row }))
|
|
178
205
|
onViolation(violation);
|
|
179
206
|
return row;
|
|
180
207
|
}
|
|
@@ -208,18 +235,20 @@ export function eraseEntityPort(port, options = {}) {
|
|
|
208
235
|
async list(query) {
|
|
209
236
|
const page = await erased.list(query);
|
|
210
237
|
for (const row of page.items)
|
|
211
|
-
checkRow(descriptor, 'list', row, onViolation);
|
|
238
|
+
checkRow({ descriptor, operation: 'list', row, onViolation });
|
|
212
239
|
return page;
|
|
213
240
|
},
|
|
214
241
|
async get(id) {
|
|
215
242
|
const row = await erased.get(id);
|
|
216
|
-
return row === null ? null : checkRow(descriptor, 'get', row, onViolation);
|
|
243
|
+
return row === null ? null : checkRow({ descriptor, operation: 'get', row, onViolation });
|
|
217
244
|
},
|
|
218
245
|
async create(input) {
|
|
219
|
-
|
|
246
|
+
const row = await erased.create(input);
|
|
247
|
+
return checkRow({ descriptor, operation: 'create', row, onViolation });
|
|
220
248
|
},
|
|
221
249
|
async update(id, patch) {
|
|
222
|
-
|
|
250
|
+
const row = await erased.update(id, patch);
|
|
251
|
+
return checkRow({ descriptor, operation: 'update', row, onViolation });
|
|
223
252
|
},
|
|
224
253
|
};
|
|
225
254
|
// Preserved by presence, not by a forwarding stub: a screen decides whether to render a delete
|
|
@@ -241,44 +270,60 @@ export function eraseEntityPort(port, options = {}) {
|
|
|
241
270
|
* @complexity Time O(fields); space O(problems).
|
|
242
271
|
* @overallScore 100
|
|
243
272
|
*/
|
|
244
|
-
function findDescriptorProblems(descriptor, registryKey, registeredNames) {
|
|
245
|
-
const problems = [];
|
|
273
|
+
function findDescriptorProblems({ descriptor, registryKey, registeredNames, }, _optional = {}) {
|
|
246
274
|
const at = `'${registryKey}'`;
|
|
275
|
+
return [
|
|
276
|
+
...findHeaderProblems({ descriptor, registryKey }),
|
|
277
|
+
...findFieldProblems({ descriptor, registeredNames }),
|
|
278
|
+
...findTitleFieldProblems({ descriptor }),
|
|
279
|
+
].map((problem) => `${at}: ${problem}`);
|
|
280
|
+
}
|
|
281
|
+
function findHeaderProblems({ descriptor, registryKey }, _optional = {}) {
|
|
282
|
+
const problems = [];
|
|
247
283
|
if (descriptor.name !== registryKey) {
|
|
248
284
|
// One entity, one name. Allowing these to differ would leave the URL segment, the relation
|
|
249
285
|
// target, and the registry key as three identifiers with no stated relationship between them.
|
|
250
|
-
problems.push(
|
|
286
|
+
problems.push(`registered under '${registryKey}' but its descriptor is named '${descriptor.name}'`);
|
|
251
287
|
}
|
|
252
288
|
if (descriptor.labelSingular === '' || descriptor.labelPlural === '') {
|
|
253
|
-
problems.push(
|
|
289
|
+
problems.push('labelSingular and labelPlural must both be non-empty');
|
|
254
290
|
}
|
|
255
291
|
if (descriptor.fields.length === 0) {
|
|
256
|
-
problems.push(
|
|
292
|
+
problems.push('declares no fields, so nothing but its id could ever be shown or edited');
|
|
257
293
|
}
|
|
294
|
+
return problems;
|
|
295
|
+
}
|
|
296
|
+
function findFieldProblems({ descriptor, registeredNames, }, _optional = {}) {
|
|
297
|
+
const problems = [];
|
|
258
298
|
const seen = new Set();
|
|
259
299
|
for (const field of descriptor.fields) {
|
|
260
300
|
if (field.name === '') {
|
|
261
|
-
problems.push(
|
|
301
|
+
problems.push('a field has an empty name');
|
|
262
302
|
continue;
|
|
263
303
|
}
|
|
264
304
|
if (field.name === IMPLICIT_ROW_KEY) {
|
|
265
|
-
problems.push(
|
|
305
|
+
problems.push(`'${IMPLICIT_ROW_KEY}' is implicit on every row and must not be declared as a field`);
|
|
266
306
|
}
|
|
267
307
|
if (seen.has(field.name))
|
|
268
|
-
problems.push(
|
|
308
|
+
problems.push(`field '${field.name}' is declared more than once`);
|
|
269
309
|
seen.add(field.name);
|
|
270
310
|
if (field.kind === 'relation' && !registeredNames.has(field.target)) {
|
|
271
|
-
problems.push(
|
|
311
|
+
problems.push(`field '${field.name}' targets '${field.target}', which is not a registered entity`);
|
|
272
312
|
}
|
|
273
313
|
}
|
|
314
|
+
return problems;
|
|
315
|
+
}
|
|
316
|
+
function findTitleFieldProblems({ descriptor }, _optional = {}) {
|
|
274
317
|
const titleField = descriptor.fields.find((field) => field.name === descriptor.titleField);
|
|
275
318
|
if (titleField === undefined) {
|
|
276
|
-
|
|
319
|
+
return [`titleField '${descriptor.titleField}' does not name a declared field`];
|
|
277
320
|
}
|
|
278
|
-
|
|
279
|
-
|
|
321
|
+
if (titleField.kind !== 'text') {
|
|
322
|
+
return [
|
|
323
|
+
`titleField '${descriptor.titleField}' is kind '${titleField.kind}'; a title must be 'text'`,
|
|
324
|
+
];
|
|
280
325
|
}
|
|
281
|
-
return
|
|
326
|
+
return [];
|
|
282
327
|
}
|
|
283
328
|
/**
|
|
284
329
|
* Assembles entity ports into a registry, validating every descriptor first.
|
|
@@ -306,7 +351,7 @@ function findDescriptorProblems(descriptor, registryKey, registeredNames) {
|
|
|
306
351
|
export function createAdminEntityRegistry(ports) {
|
|
307
352
|
const entries = Object.entries(ports);
|
|
308
353
|
const registeredNames = new Set(entries.map(([, port]) => port.descriptor.name));
|
|
309
|
-
const problems = entries.flatMap(([key, port]) => findDescriptorProblems(port.descriptor, key, registeredNames));
|
|
354
|
+
const problems = entries.flatMap(([key, port]) => findDescriptorProblems({ descriptor: port.descriptor, registryKey: key, registeredNames }));
|
|
310
355
|
if (problems.length > 0) {
|
|
311
356
|
throw new Error(`createAdminEntityRegistry: invalid descriptors\n- ${problems.join('\n- ')}`);
|
|
312
357
|
}
|