@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
|
@@ -125,7 +125,17 @@ function identityLabel(config) {
|
|
|
125
125
|
* @complexity O(1) — one attribute read, one `JSON.parse` of a small config object, one table lookup.
|
|
126
126
|
*/
|
|
127
127
|
export function describeEmbedPlaceholder({ el }) {
|
|
128
|
-
const
|
|
128
|
+
const marker = parseEmbedConfig(el.getAttribute('data-embed-config'));
|
|
129
|
+
if (!marker)
|
|
130
|
+
return undefined;
|
|
131
|
+
const { config } = marker;
|
|
132
|
+
const type = marker.type.toLowerCase();
|
|
133
|
+
if (type === 'gallery')
|
|
134
|
+
return { kindLabel: EMBED_KIND_LABELS.gallery, identityLabel: galleryIdentityLabel(config) };
|
|
135
|
+
return { kindLabel: EMBED_KIND_LABELS[type] ?? titleCase(type), identityLabel: identityLabel(config) };
|
|
136
|
+
}
|
|
137
|
+
/** A marker's config object and its non-empty string `type`, or `undefined` for anything else. */
|
|
138
|
+
function parseEmbedConfig(raw) {
|
|
129
139
|
if (!raw)
|
|
130
140
|
return undefined;
|
|
131
141
|
let parsed;
|
|
@@ -140,12 +150,11 @@ export function describeEmbedPlaceholder({ el }) {
|
|
|
140
150
|
const config = parsed;
|
|
141
151
|
if (typeof config.type !== 'string' || config.type === '')
|
|
142
152
|
return undefined;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
return { kindLabel: EMBED_KIND_LABELS[type] ?? titleCase(type), identityLabel: identityLabel(config) };
|
|
153
|
+
return { config, type: config.type };
|
|
154
|
+
}
|
|
155
|
+
function galleryIdentityLabel(config) {
|
|
156
|
+
const count = Array.isArray(config.images) ? config.images.length : 0;
|
|
157
|
+
return count === 0 ? 'no images set' : `${count} ${count === 1 ? 'image' : 'images'}`;
|
|
149
158
|
}
|
|
150
159
|
export function InteractiveHtmlEditor(props) {
|
|
151
160
|
return (_jsx(HtmlEditor, { ...props, isProtectedElement: (el) => isProtectedEmbedElement({ el }), describeEmbedPlaceholder: (el) => describeEmbedPlaceholder({ el }) }));
|
|
@@ -17,6 +17,21 @@ import { useRowMenu } from './RowMenu.hooks.js';
|
|
|
17
17
|
function rowMenuAgentProps(handle, options) {
|
|
18
18
|
return handle === undefined ? {} : agentHandle({ handle }, options);
|
|
19
19
|
}
|
|
20
|
+
function rowMenuPopupClassName(position) {
|
|
21
|
+
return `row-menu-popup${position?.placement === 'above' ? ' row-menu-popup-above' : ''}`;
|
|
22
|
+
}
|
|
23
|
+
function rowMenuPopupStyle(position) {
|
|
24
|
+
return {
|
|
25
|
+
position: 'fixed',
|
|
26
|
+
top: position ? position.top : -9999,
|
|
27
|
+
left: position ? position.left : -9999,
|
|
28
|
+
// Laid out but invisible until the first `reposition()` measurement lands (see
|
|
29
|
+
// `RowMenu.hooks.tsx`) — keeps `offsetHeight`/`offsetWidth` measurable without a
|
|
30
|
+
// visible flash at the wrong coordinates.
|
|
31
|
+
visibility: position ? 'visible' : 'hidden',
|
|
32
|
+
transform: position?.placement === 'above' ? 'translateY(-100%)' : undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
20
35
|
export function RowMenu({ useRowMenu: useRowMenuState = useRowMenu, agentHandle: baseHandle, ...props }) {
|
|
21
36
|
const { open, position, triggerRef, menuRef, itemRefs, onTriggerClick, onTriggerKeyDown, onMenuKeyDown, selectItem, } = useRowMenuState({ itemCount: props.items.length, window, document });
|
|
22
37
|
// One base handle per item, positionally aligned with `props.items` — undefined (rather than an
|
|
@@ -26,16 +41,7 @@ export function RowMenu({ useRowMenu: useRowMenuState = useRowMenu, agentHandle:
|
|
|
26
41
|
? buildAgentListHandles({ prefix: `${baseHandle}-item`, ids: props.items.map((item) => item.key) })
|
|
27
42
|
: undefined;
|
|
28
43
|
return (_jsxs(_Fragment, { children: [_jsx("button", { ref: triggerRef, type: "button", className: "row-menu-trigger", "aria-label": props.triggerLabel, "aria-haspopup": "menu", "aria-expanded": open, onClick: onTriggerClick, onKeyDown: onTriggerKeyDown, ...rowMenuAgentProps(baseHandle, { role: 'button', label: props.triggerLabel }), children: _jsxs("svg", { viewBox: "0 0 18 18", fill: "currentColor", "aria-hidden": "true", children: [_jsx("circle", { cx: "9", cy: "4.5", r: "1.5" }), _jsx("circle", { cx: "9", cy: "9", r: "1.5" }), _jsx("circle", { cx: "9", cy: "13.5", r: "1.5" })] }) }), open
|
|
29
|
-
? createPortal(_jsx("div", { ref: menuRef, role: "menu", "aria-label": props.triggerLabel, className:
|
|
30
|
-
position: 'fixed',
|
|
31
|
-
top: position ? position.top : -9999,
|
|
32
|
-
left: position ? position.left : -9999,
|
|
33
|
-
// Laid out but invisible until the first `reposition()` measurement lands (see
|
|
34
|
-
// `RowMenu.hooks.tsx`) — keeps `offsetHeight`/`offsetWidth` measurable without a
|
|
35
|
-
// visible flash at the wrong coordinates.
|
|
36
|
-
visibility: position ? 'visible' : 'hidden',
|
|
37
|
-
transform: position?.placement === 'above' ? 'translateY(-100%)' : undefined,
|
|
38
|
-
}, onKeyDown: onMenuKeyDown, children: props.items.map((item, index) => {
|
|
44
|
+
? createPortal(_jsx("div", { ref: menuRef, role: "menu", "aria-label": props.triggerLabel, className: rowMenuPopupClassName(position), style: rowMenuPopupStyle(position), onKeyDown: onMenuKeyDown, children: props.items.map((item, index) => {
|
|
39
45
|
const toneClass = toneClassName({ tone: resolveTone({}, item) });
|
|
40
46
|
return (_jsx("button", { ref: (el) => {
|
|
41
47
|
itemRefs.current[index] = el;
|
|
@@ -210,34 +210,40 @@ function SidebarNav(props) {
|
|
|
210
210
|
*/
|
|
211
211
|
const collapsible = Boolean(group.label) && !collapsed && (props.collapsibleGroups ?? []).includes(group.label);
|
|
212
212
|
const open = !collapsible || sections.isOpen({ groupLabel: group.label });
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
// activation, and the button role all come free, and `aria-expanded` only carries its
|
|
217
|
-
// meaning on an element that is actually a control.
|
|
218
|
-
_jsxs("button", { type: "button", className: `cms-group cms-group-toggle${open ? '' : ' is-closed'}`, "aria-expanded": open, "aria-controls": panelId, onClick: () => sections.toggle({ groupLabel: group.label }), children: [_jsx("span", { children: group.label }), _jsx("svg", { className: "cms-group-chevron", viewBox: "0 0 18 18", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M5 7l4 4 4-4", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) })] })) : group.label ? (_jsx("div", { className: "cms-group", children: group.label })) : null, _jsx("div", { className: "cms-section-items", id: panelId, hidden: !open, children: group.items.map((item) => {
|
|
219
|
-
const active = item.id === activeId;
|
|
220
|
-
const href = adminHref({ routePath: item.href }, { base });
|
|
221
|
-
if (props.renderItem) {
|
|
222
|
-
return _jsx(Fragment, { children: props.renderItem(item, { active, href, collapsed }) }, item.id);
|
|
223
|
-
}
|
|
224
|
-
if (item.soon && !item.soonPreviewable) {
|
|
225
|
-
return (_jsxs("div", { className: "cms-item is-soon", "aria-disabled": "true", children: [_jsx(Icon, { markup: item.icon ?? '' }), _jsx("span", { children: item.label }), _jsx("span", { className: "soon", children: props.soonLabel ?? 'Soon' })] }, item.id));
|
|
226
|
-
}
|
|
227
|
-
if (item.soon) {
|
|
228
|
-
// `soonPreviewable`: same real `<a>` as a shipped row (active state, aria-current,
|
|
229
|
-
// rail tooltip, cmd-click/copy-link all work identically) with the `soon` badge kept
|
|
230
|
-
// alongside the label — the announcement stays honest, but the row is no longer inert.
|
|
231
|
-
return (_jsxs("a", { className: `cms-item is-soon${active ? ' active' : ''}`, href: href, "aria-current": active ? 'page' : undefined, ...railTooltipProps(item.label), children: [_jsx(Icon, { markup: item.icon ?? '' }), _jsx("span", { children: item.label }), _jsx("span", { className: "soon", children: props.soonLabel ?? 'Soon' })] }, item.id));
|
|
232
|
-
}
|
|
233
|
-
return (_jsxs("a", { className: `cms-item${active ? ' active' : ''}`,
|
|
234
|
-
// `AdminNavItem.href` is a route path; the base is applied here so the panel
|
|
235
|
-
// manifest never has to know it. A real URL, so cmd-click and "copy link address"
|
|
236
|
-
// behave normally.
|
|
237
|
-
href: href, "aria-current": active ? 'page' : undefined, ...railTooltipProps(item.label), children: [_jsx(Icon, { markup: item.icon ?? '' }), _jsx("span", { children: item.label })] }, item.id));
|
|
238
|
-
}) })] }, group.label ?? `top-${gi}`));
|
|
213
|
+
const sectionKey = group.label ?? `top-${gi}`;
|
|
214
|
+
const panelId = `cms-section-${sectionKey.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
|
215
|
+
return (_jsxs("div", { className: "cms-section", children: [_jsx(NavGroupHeading, { label: group.label, collapsible: collapsible, open: open, panelId: panelId, onToggle: () => sections.toggle({ groupLabel: group.label }) }), _jsx("div", { className: "cms-section-items", id: panelId, hidden: !open, children: group.items.map((item) => (_jsx(NavItemRow, { item: item, nav: props, active: item.id === activeId, href: adminHref({ routePath: item.href }, { base }), collapsed: collapsed, railTooltipProps: railTooltipProps }, item.id))) })] }, sectionKey));
|
|
239
216
|
}) }));
|
|
240
217
|
}
|
|
218
|
+
function NavGroupHeading({ label, collapsible, open, panelId, onToggle }) {
|
|
219
|
+
if (!label)
|
|
220
|
+
return null;
|
|
221
|
+
if (!collapsible)
|
|
222
|
+
return _jsx("div", { className: "cms-group", children: label });
|
|
223
|
+
return (
|
|
224
|
+
// A real `<button>`, not a click-handled `<div>`: keyboard focus, Enter/Space
|
|
225
|
+
// activation, and the button role all come free, and `aria-expanded` only carries its
|
|
226
|
+
// meaning on an element that is actually a control.
|
|
227
|
+
_jsxs("button", { type: "button", className: `cms-group cms-group-toggle${open ? '' : ' is-closed'}`, "aria-expanded": open, "aria-controls": panelId, onClick: onToggle, children: [_jsx("span", { children: label }), _jsx("svg", { className: "cms-group-chevron", viewBox: "0 0 18 18", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M5 7l4 4 4-4", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) })] }));
|
|
228
|
+
}
|
|
229
|
+
function DisabledSoonItem({ item, soonLabel }) {
|
|
230
|
+
return (_jsxs("div", { className: "cms-item is-soon", "aria-disabled": "true", children: [_jsx(Icon, { markup: item.icon ?? '' }), _jsx("span", { children: item.label }), _jsx("span", { className: "soon", children: soonLabel })] }));
|
|
231
|
+
}
|
|
232
|
+
function NavItemRow({ item, nav, active, href, collapsed, railTooltipProps }) {
|
|
233
|
+
if (nav.renderItem)
|
|
234
|
+
return _jsx(_Fragment, { children: nav.renderItem(item, { active, href, collapsed }) });
|
|
235
|
+
const soonLabel = nav.soonLabel ?? 'Soon';
|
|
236
|
+
if (item.soon && !item.soonPreviewable)
|
|
237
|
+
return _jsx(DisabledSoonItem, { item: item, soonLabel: soonLabel });
|
|
238
|
+
// `soonPreviewable`: same real `<a>` as a shipped row (active state, aria-current,
|
|
239
|
+
// rail tooltip, cmd-click/copy-link all work identically) with the `soon` badge kept
|
|
240
|
+
// alongside the label — the announcement stays honest, but the row is no longer inert.
|
|
241
|
+
return (_jsxs("a", { className: `cms-item${item.soon ? ' is-soon' : ''}${active ? ' active' : ''}`,
|
|
242
|
+
// `AdminNavItem.href` is a route path; the base is applied here so the panel
|
|
243
|
+
// manifest never has to know it. A real URL, so cmd-click and "copy link address"
|
|
244
|
+
// behave normally.
|
|
245
|
+
href: href, "aria-current": active ? 'page' : undefined, ...railTooltipProps(item.label), children: [_jsx(Icon, { markup: item.icon ?? '' }), _jsx("span", { children: item.label }), item.soon ? _jsx("span", { className: "soon", children: soonLabel }) : null] }));
|
|
246
|
+
}
|
|
241
247
|
/** The bottom region. Carries no content of its own — the host decides what belongs there. */
|
|
242
248
|
function SidebarFooter(props) {
|
|
243
249
|
return _jsx("div", { className: ['cms-foot', props.className].filter(Boolean).join(' '), children: props.children });
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useId, useMemo } from 'react';
|
|
3
3
|
import { EntityField } from './EntityField.js';
|
|
4
|
+
import { RelationTruncationNotice } from './RelationTruncationNotice.js';
|
|
4
5
|
import { useEntityEdit } from './use-entity-edit.js';
|
|
5
6
|
import { entityText } from './types.js';
|
|
6
|
-
|
|
7
|
+
function EntityFieldRow({ edit, form, field, inputId, relations, translate }) {
|
|
8
|
+
const missing = edit.missing.includes(field.name);
|
|
9
|
+
const invalid = edit.invalid.some((problem) => problem.field === field.name);
|
|
10
|
+
const jsonError = form.jsonErrors[field.name] === true;
|
|
11
|
+
return _jsxs("div", { children: [_jsxs("label", { htmlFor: inputId, children: [translate({ key: field.label ?? field.name }), field.required ? ' *' : '', field.kind === 'datetime' ? ` (${translate({ key: 'UTC' })})` : ''] }), _jsx(EntityField, { inputId: inputId, field: field, value: form.draft[field.name], text: form.jsonText[field.name] ?? '', relations: relations, translate: translate, disabled: edit.pending !== null, invalid: missing || invalid || jsonError, onChange: ({ value }) => edit.setField({ name: field.name, value }), onTextChange: ({ text }) => edit.setJsonText({ name: field.name, text }) }), missing ? _jsx("p", { children: translate({ key: 'Required' }) }) : null, invalid ? _jsx("p", { children: translate({ key: 'Invalid value' }) }) : null, jsonError ? _jsx("p", { role: "alert", children: translate({ key: 'Invalid JSON' }) }) : null] });
|
|
12
|
+
}
|
|
13
|
+
function EditActions({ edit, canRemove, translate }) {
|
|
14
|
+
return _jsxs(_Fragment, { children: [_jsx("button", { type: "submit", disabled: !edit.canSave, children: translate({ key: edit.pending === 'save' ? 'Saving' : 'Save' }) }), canRemove ? _jsx("button", { type: "button", disabled: edit.pending !== null, onClick: () => { void edit.remove(); }, children: translate({ key: edit.pending === 'remove' ? 'Deleting' : 'Delete' }) }) : null] });
|
|
15
|
+
}
|
|
7
16
|
function EntityEditor(props) {
|
|
8
17
|
const { port, translate, routes, entityName, id } = props;
|
|
9
18
|
const formId = useId();
|
|
@@ -12,19 +21,13 @@ function EntityEditor(props) {
|
|
|
12
21
|
return _jsx("p", { role: "alert", children: translate({ key: 'Unable to load row' }) });
|
|
13
22
|
if (edit.read.loading || !edit.read.data)
|
|
14
23
|
return _jsx("p", { role: "status", children: translate({ key: 'Loading' }) });
|
|
15
|
-
|
|
24
|
+
// useEntityEdit builds the form in the same render its row arrives, so once data has loaded the
|
|
25
|
+
// only way to have no form is an edit route whose row does not exist.
|
|
26
|
+
const { form } = edit;
|
|
27
|
+
if (!form)
|
|
16
28
|
return _jsx("p", { role: "status", children: translate({ key: 'Row not found' }) });
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const busy = edit.pending !== null;
|
|
20
|
-
const truncated = Object.entries(edit.read.data.relations).filter(([, target]) => target.truncated).map(([name]) => name);
|
|
21
|
-
return _jsxs("form", { className: "page", onSubmit: (e) => { e.preventDefault(); void edit.save(); }, children: [_jsx("a", { href: routes.href({ entity: entityName }), children: translate({ key: port.descriptor.labelPlural }) }), _jsx("h1", { children: entityText({ translate, key: id === null ? 'New {entity}' : 'Edit {entity}', values: { entity: translate({ key: port.descriptor.labelSingular }) } }) }), edit.failed ? _jsx("p", { role: "alert", children: translate({ key: 'Operation failed' }) }) : null, port.descriptor.fields.map((field, index) => {
|
|
22
|
-
const inputId = `${formId}-${index}`;
|
|
23
|
-
const missing = edit.missing.includes(field.name);
|
|
24
|
-
const invalid = edit.invalid.some((problem) => problem.field === field.name);
|
|
25
|
-
const jsonError = edit.form?.jsonErrors[field.name] === true;
|
|
26
|
-
return _jsxs("div", { children: [_jsxs("label", { htmlFor: inputId, children: [translate({ key: field.label ?? field.name }), field.required ? ' *' : '', field.kind === 'datetime' ? ` (${translate({ key: 'UTC' })})` : ''] }), _jsx(EntityField, { inputId: inputId, field: field, value: edit.form?.draft[field.name], text: edit.form?.jsonText[field.name] ?? '', relations: edit.read.data.relations, translate: translate, disabled: busy, invalid: missing || invalid || jsonError, onChange: ({ value }) => edit.setField({ name: field.name, value }), onTextChange: ({ text }) => edit.setJsonText({ name: field.name, text }) }), missing ? _jsx("p", { children: translate({ key: 'Required' }) }) : null, invalid ? _jsx("p", { children: translate({ key: 'Invalid value' }) }) : null, jsonError ? _jsx("p", { role: "alert", children: translate({ key: 'Invalid JSON' }) }) : null] }, field.name);
|
|
27
|
-
}), truncated.length > 0 ? _jsx("p", { children: entityText({ translate, key: 'Related names limited to {limit} rows: {targets}', values: { limit: RELATION_OPTION_LIMIT, targets: truncated.join(', ') } }) }) : null, _jsx("button", { type: "submit", disabled: !edit.canSave, children: translate({ key: edit.pending === 'save' ? 'Saving' : 'Save' }) }), id !== null && port.remove ? _jsx("button", { type: "button", disabled: busy, onClick: () => { void edit.remove(); }, children: translate({ key: edit.pending === 'remove' ? 'Deleting' : 'Delete' }) }) : null] });
|
|
29
|
+
const { relations } = edit.read.data;
|
|
30
|
+
return _jsxs("form", { className: "page", onSubmit: (e) => { e.preventDefault(); void edit.save(); }, children: [_jsx("a", { href: routes.href({ entity: entityName }), children: translate({ key: port.descriptor.labelPlural }) }), _jsx("h1", { children: entityText({ translate, key: id === null ? 'New {entity}' : 'Edit {entity}', values: { entity: translate({ key: port.descriptor.labelSingular }) } }) }), edit.failed ? _jsx("p", { role: "alert", children: translate({ key: 'Operation failed' }) }) : null, port.descriptor.fields.map((field, index) => _jsx(EntityFieldRow, { edit: edit, form: form, field: field, inputId: `${formId}-${index}`, relations: relations, translate: translate }, field.name)), _jsx(RelationTruncationNotice, { relations: relations, translate: translate }), _jsx(EditActions, { edit: edit, canRemove: id !== null && port.remove !== undefined, translate: translate })] });
|
|
28
31
|
}
|
|
29
32
|
/** Host entity editing uses the existing registry/ports and core draft validation.
|
|
30
33
|
* Required booleans default false, optional booleans remain unset, and invalid JSON blocks save
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
1
2
|
import type { AdminEntityField } from '../../core/ports/entities.js';
|
|
2
3
|
import { type EntityTranslate } from './types.js';
|
|
3
4
|
import { type RelationIndex } from './rules.js';
|
|
4
|
-
|
|
5
|
+
interface EntityFieldProps {
|
|
5
6
|
readonly inputId: string;
|
|
6
7
|
readonly field: AdminEntityField;
|
|
7
8
|
readonly value: unknown;
|
|
@@ -16,5 +17,7 @@ export declare function EntityField(props: {
|
|
|
16
17
|
readonly onTextChange: (args: {
|
|
17
18
|
readonly text: string;
|
|
18
19
|
}) => void;
|
|
19
|
-
}
|
|
20
|
+
}
|
|
21
|
+
export declare function EntityField(props: EntityFieldProps): ReactNode;
|
|
22
|
+
export {};
|
|
20
23
|
//# sourceMappingURL=EntityField.d.ts.map
|
|
@@ -2,32 +2,47 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Select } from '@jini-ai/ui/admin-widgets';
|
|
3
3
|
import { selectTranslate } from './types.js';
|
|
4
4
|
import { fromDatetimeLocalValue, toDatetimeLocalValue } from './rules.js';
|
|
5
|
+
const fieldLabel = ({ field, translate }) => translate({ key: field.label ?? field.name });
|
|
6
|
+
const blankToUndefined = (raw) => (raw === '' ? undefined : raw);
|
|
7
|
+
const commonProps = ({ inputId, disabled, invalid }) => ({ id: inputId, disabled, 'aria-invalid': invalid });
|
|
8
|
+
function RelationInput(props) {
|
|
9
|
+
const { inputId, field, value, translate, disabled, onChange } = props;
|
|
10
|
+
const options = props.relations[field.target]?.options ?? [];
|
|
11
|
+
const selectedId = typeof value === 'string' ? value : '';
|
|
12
|
+
const missing = selectedId !== '' && !options.some((option) => option.id === selectedId);
|
|
13
|
+
return _jsx(Select, { id: inputId, disabled: disabled, value: selectedId, translate: selectTranslate({ translate }), "aria-label": fieldLabel(props), options: [
|
|
14
|
+
{ value: '', label: translate({ key: 'No value' }) },
|
|
15
|
+
...(missing ? [{ value: selectedId, label: `${selectedId} (${translate({ key: 'Unresolved' })})` }] : []),
|
|
16
|
+
...options.map((option) => ({ value: option.id, label: option.title })),
|
|
17
|
+
], onChange: (selected) => onChange({ value: blankToUndefined(selected) }) });
|
|
18
|
+
}
|
|
19
|
+
function booleanSelectValue(value) {
|
|
20
|
+
if (value === undefined)
|
|
21
|
+
return '';
|
|
22
|
+
return value === true ? 'true' : 'false';
|
|
23
|
+
}
|
|
24
|
+
function BooleanInput(props) {
|
|
25
|
+
const { inputId, field, value, translate, disabled, onChange } = props;
|
|
26
|
+
if (field.required)
|
|
27
|
+
return _jsx("input", { ...commonProps(props), type: "checkbox", checked: value === true, onChange: (e) => onChange({ value: e.target.checked }) });
|
|
28
|
+
return _jsx(Select, { id: inputId, disabled: disabled, value: booleanSelectValue(value), translate: selectTranslate({ translate }), "aria-label": fieldLabel(props), options: [
|
|
29
|
+
{ value: '', label: translate({ key: 'No value' }) }, { value: 'true', label: translate({ key: 'Yes' }) }, { value: 'false', label: translate({ key: 'No' }) },
|
|
30
|
+
], onChange: (selected) => onChange({ value: selected === '' ? undefined : selected === 'true' }) });
|
|
31
|
+
}
|
|
32
|
+
function NumberInput(props) {
|
|
33
|
+
const { field, value, onChange } = props;
|
|
34
|
+
return _jsx("input", { ...commonProps(props), type: "number", step: field.kind === 'integer' ? '1' : 'any', value: typeof value === 'number' ? value : '', onChange: (e) => onChange({ value: e.target.value === '' ? undefined : Number(e.target.value) }) });
|
|
35
|
+
}
|
|
5
36
|
export function EntityField(props) {
|
|
6
|
-
const {
|
|
7
|
-
const common = { id: inputId, disabled, 'aria-invalid': invalid };
|
|
8
|
-
const selectT = selectTranslate({ translate });
|
|
37
|
+
const { field, value, onChange } = props;
|
|
9
38
|
switch (field.kind) {
|
|
10
|
-
case 'json': return _jsx("textarea", { ...
|
|
11
|
-
case 'relation': {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const missing = selectedId !== '' && !options.some((option) => option.id === selectedId);
|
|
15
|
-
return _jsx(Select, { id: inputId, disabled: disabled, value: selectedId, translate: selectT, "aria-label": translate({ key: field.label ?? field.name }), options: [
|
|
16
|
-
{ value: '', label: translate({ key: 'No value' }) },
|
|
17
|
-
...(missing ? [{ value: selectedId, label: `${selectedId} (${translate({ key: 'Unresolved' })})` }] : []),
|
|
18
|
-
...options.map((option) => ({ value: option.id, label: option.title })),
|
|
19
|
-
], onChange: (selected) => onChange({ value: selected === '' ? undefined : selected }) });
|
|
20
|
-
}
|
|
21
|
-
case 'boolean':
|
|
22
|
-
if (field.required)
|
|
23
|
-
return _jsx("input", { ...common, type: "checkbox", checked: value === true, onChange: (e) => onChange({ value: e.target.checked }) });
|
|
24
|
-
return _jsx(Select, { id: inputId, disabled: disabled, value: value === undefined ? '' : value === true ? 'true' : 'false', translate: selectT, "aria-label": translate({ key: field.label ?? field.name }), options: [
|
|
25
|
-
{ value: '', label: translate({ key: 'No value' }) }, { value: 'true', label: translate({ key: 'Yes' }) }, { value: 'false', label: translate({ key: 'No' }) },
|
|
26
|
-
], onChange: (selected) => onChange({ value: selected === '' ? undefined : selected === 'true' }) });
|
|
27
|
-
case 'datetime': return _jsx("input", { ...common, type: "datetime-local", step: "0.001", value: toDatetimeLocalValue({ value }), onChange: (e) => onChange({ value: fromDatetimeLocalValue({ raw: e.target.value }) }) });
|
|
39
|
+
case 'json': return _jsx("textarea", { ...commonProps(props), value: props.text, onChange: (e) => props.onTextChange({ text: e.target.value }) });
|
|
40
|
+
case 'relation': return _jsx(RelationInput, { ...props, field: field });
|
|
41
|
+
case 'boolean': return _jsx(BooleanInput, { ...props });
|
|
42
|
+
case 'datetime': return _jsx("input", { ...commonProps(props), type: "datetime-local", step: "0.001", value: toDatetimeLocalValue({ value }), onChange: (e) => onChange({ value: fromDatetimeLocalValue({ raw: e.target.value }) }) });
|
|
28
43
|
case 'integer':
|
|
29
|
-
case 'real': return _jsx(
|
|
30
|
-
case 'text': return _jsx("input", { ...
|
|
44
|
+
case 'real': return _jsx(NumberInput, { ...props });
|
|
45
|
+
case 'text': return _jsx("input", { ...commonProps(props), value: typeof value === 'string' ? value : '', onChange: (e) => onChange({ value: blankToUndefined(e.target.value) }) });
|
|
31
46
|
}
|
|
32
47
|
}
|
|
33
48
|
//# sourceMappingURL=EntityField.js.map
|
|
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useCallback, useMemo, useState } from 'react';
|
|
3
3
|
import { DataTable } from '../components/DataTable.js';
|
|
4
4
|
import { EntityCell } from './EntityCell.js';
|
|
5
|
+
import { RelationTruncationNotice } from './RelationTruncationNotice.js';
|
|
5
6
|
import { entityText } from './types.js';
|
|
6
|
-
import { loadRelationIndex, resolveEntityPageSize
|
|
7
|
+
import { loadRelationIndex, resolveEntityPageSize } from './rules.js';
|
|
7
8
|
import { useEntityRead } from './use-entity-read.js';
|
|
8
9
|
/** Cursor history records actual returned page lengths; never sort a server-paginated page locally.
|
|
9
10
|
* Host translation, registry and navigation are injected, and this list uses the existing DataTable.
|
|
@@ -15,9 +16,10 @@ export function EntityList(props) {
|
|
|
15
16
|
const limit = resolveEntityPageSize({ raw: props.limitParam });
|
|
16
17
|
const token = JSON.stringify([entityName, limit]);
|
|
17
18
|
const [walk, setWalk] = useState({ token, port, entries: [{ cursor: null, offset: 0 }] });
|
|
18
|
-
|
|
19
|
+
const walkIsCurrent = walk.token === token && walk.port === port;
|
|
20
|
+
if (!walkIsCurrent)
|
|
19
21
|
setWalk({ token, port, entries: [{ cursor: null, offset: 0 }] });
|
|
20
|
-
const entries =
|
|
22
|
+
const entries = walkIsCurrent ? walk.entries : [{ cursor: null, offset: 0 }];
|
|
21
23
|
const position = entries[entries.length - 1];
|
|
22
24
|
const load = useCallback(async () => {
|
|
23
25
|
if (port === null)
|
|
@@ -37,7 +39,6 @@ export function EntityList(props) {
|
|
|
37
39
|
return _jsx("p", { role: "status", children: translate({ key: 'Loading' }) });
|
|
38
40
|
const { page, relations } = result.data;
|
|
39
41
|
const descriptor = port.descriptor;
|
|
40
|
-
const truncated = Object.entries(relations).filter(([, options]) => options.truncated).map(([name]) => name);
|
|
41
42
|
return _jsxs("div", { className: "page", children: [_jsx("a", { href: routes.href({}), children: translate({ key: 'Data' }) }), _jsx("h1", { children: translate({ key: descriptor.labelPlural }) }), _jsx("a", { href: routes.href({ entity: entityName, view: 'create' }), children: entityText({ translate, key: 'New {entity}', values: { entity: translate({ key: descriptor.labelSingular }) } }) }), _jsx(DataTable, { rows: [...page.items], rowKey: (row) => row.id, empty: _jsx("p", { children: translate({ key: 'No rows' }) }), columns: descriptor.fields.map((field) => ({
|
|
42
43
|
key: field.name, header: translate({ key: field.label ?? field.name }),
|
|
43
44
|
// Sorting a server-cut page would misrepresent collection order.
|
|
@@ -47,6 +48,6 @@ export function EntityList(props) {
|
|
|
47
48
|
})) }), _jsx("p", { children: page.items.length === 0 ? translate({ key: 'No rows' }) : entityText({ translate, key: 'Showing {from}–{to}', values: { from: position.offset + 1, to: position.offset + page.items.length } }) }), _jsx("button", { type: "button", disabled: entries.length === 1, onClick: () => setWalk((current) => ({ ...current, entries: current.entries.slice(0, -1) })), children: translate({ key: 'Previous' }) }), _jsx("button", { type: "button", disabled: page.nextCursor === null, onClick: () => {
|
|
48
49
|
if (page.nextCursor !== null)
|
|
49
50
|
setWalk((current) => ({ ...current, entries: [...current.entries, { cursor: page.nextCursor, offset: position.offset + page.items.length }] }));
|
|
50
|
-
}, children: translate({ key: 'Next' }) }),
|
|
51
|
+
}, children: translate({ key: 'Next' }) }), _jsx(RelationTruncationNotice, { relations: relations, translate: translate })] });
|
|
51
52
|
}
|
|
52
53
|
//# sourceMappingURL=EntityList.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { type EntityTranslate } from './types.js';
|
|
3
|
+
import { type RelationIndex } from './rules.js';
|
|
4
|
+
/** Names every relation target whose option set was cut at the limit; renders nothing otherwise. */
|
|
5
|
+
export declare function RelationTruncationNotice({ relations, translate }: {
|
|
6
|
+
readonly relations: RelationIndex;
|
|
7
|
+
readonly translate: EntityTranslate;
|
|
8
|
+
}): ReactNode;
|
|
9
|
+
//# sourceMappingURL=RelationTruncationNotice.d.ts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { entityText } from './types.js';
|
|
3
|
+
import { RELATION_OPTION_LIMIT } from './rules.js';
|
|
4
|
+
/** Names every relation target whose option set was cut at the limit; renders nothing otherwise. */
|
|
5
|
+
export function RelationTruncationNotice({ relations, translate }) {
|
|
6
|
+
const truncated = Object.entries(relations).filter(([, options]) => options.truncated).map(([name]) => name);
|
|
7
|
+
return truncated.length > 0 ? _jsx("p", { children: entityText({ translate, key: 'Related names limited to {limit} rows: {targets}', values: { limit: RELATION_OPTION_LIMIT, targets: truncated.join(', ') } }) }) : null;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=RelationTruncationNotice.js.map
|
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
import type { AdminErasedEntityPort } from '../../core/ports/entities.js';
|
|
2
2
|
import type { EntityEditProps } from './types.js';
|
|
3
|
+
import { type EntityLoadedRow } from './use-entity-read.js';
|
|
3
4
|
interface FormState {
|
|
4
5
|
readonly draft: Record<string, unknown>;
|
|
5
6
|
readonly jsonText: Readonly<Record<string, string>>;
|
|
6
7
|
readonly jsonErrors: Readonly<Record<string, boolean>>;
|
|
7
8
|
}
|
|
9
|
+
type MutationKind = 'save' | 'remove';
|
|
8
10
|
/** Called by the editor mounted for one entity/id pair. */
|
|
9
11
|
export declare function useEntityEdit({ port, ...props }: EntityEditProps & {
|
|
10
12
|
readonly port: AdminErasedEntityPort;
|
|
11
13
|
}): {
|
|
12
14
|
form: FormState | null;
|
|
13
15
|
read: {
|
|
14
|
-
readonly data:
|
|
16
|
+
readonly data: EntityLoadedRow | undefined;
|
|
15
17
|
readonly error: unknown;
|
|
16
18
|
readonly loading: boolean;
|
|
17
19
|
};
|
|
18
20
|
missing: readonly string[];
|
|
19
21
|
invalid: readonly import("./rules.js").DraftFieldProblem[];
|
|
20
22
|
canSave: boolean;
|
|
21
|
-
pending:
|
|
23
|
+
pending: MutationKind | null;
|
|
22
24
|
failed: boolean;
|
|
23
25
|
setField: ({ name, value }: {
|
|
24
26
|
readonly name: string;
|
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { draftForRow, invalidDraftFields, missingRequiredFields } from './rules.js';
|
|
3
3
|
import { loadEntityRow, useEntityRead } from './use-entity-read.js';
|
|
4
|
+
/** The first form for a loaded row, or null while it is loading or an edited row was not found. */
|
|
5
|
+
function initialForm({ descriptor, loaded, id }) {
|
|
6
|
+
if (!loaded || (id !== null && loaded.row === null))
|
|
7
|
+
return null;
|
|
8
|
+
const draft = draftForRow({ descriptor, row: loaded.row });
|
|
9
|
+
const jsonText = Object.create(null);
|
|
10
|
+
for (const field of descriptor.fields) {
|
|
11
|
+
if (field.kind === 'json')
|
|
12
|
+
jsonText[field.name] = draft[field.name] === undefined ? '' : JSON.stringify(draft[field.name], null, 2);
|
|
13
|
+
}
|
|
14
|
+
return { draft, jsonText, jsonErrors: {} };
|
|
15
|
+
}
|
|
16
|
+
function canSaveForm({ form, missing, invalid, pending }) {
|
|
17
|
+
if (form === null || pending !== null)
|
|
18
|
+
return false;
|
|
19
|
+
return missing.length === 0 && invalid.length === 0 && !Object.values(form.jsonErrors).some(Boolean);
|
|
20
|
+
}
|
|
21
|
+
function parseJsonText(text) {
|
|
22
|
+
try {
|
|
23
|
+
return { value: text.trim() === '' ? undefined : JSON.parse(text), failed: false };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { value: undefined, failed: true };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function mutationAllowed({ kind, canSave, id, port }) {
|
|
30
|
+
if (kind === 'save')
|
|
31
|
+
return canSave;
|
|
32
|
+
return id !== null && port.remove !== undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Run the port call; resolves to the saved row's id, or null after a removal. */
|
|
35
|
+
async function performMutation({ kind, port, id, draft }) {
|
|
36
|
+
if (kind === 'remove' && id !== null && port.remove) {
|
|
37
|
+
await port.remove(id);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
// A create omits blanks; an update carries explicit undefined to clear optional fields.
|
|
41
|
+
const saved = id === null
|
|
42
|
+
? await port.create(Object.fromEntries(Object.entries(draft).filter(([, value]) => value !== undefined)))
|
|
43
|
+
: await port.update(id, draft);
|
|
44
|
+
return saved.id;
|
|
45
|
+
}
|
|
4
46
|
/** Called by the editor mounted for one entity/id pair. */
|
|
5
47
|
export function useEntityEdit({ port, ...props }) {
|
|
6
48
|
const { entityName, id, registry, routes } = props;
|
|
@@ -15,22 +57,16 @@ export function useEntityEdit({ port, ...props }) {
|
|
|
15
57
|
const [mutation, setMutation] = useState({ scope, kind: null, failed: false });
|
|
16
58
|
const busy = useRef(false);
|
|
17
59
|
let form = formState?.scope === scope ? formState.form : null;
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (field.kind === 'json')
|
|
23
|
-
jsonText[field.name] = draft[field.name] === undefined ? '' : JSON.stringify(draft[field.name], null, 2);
|
|
24
|
-
}
|
|
25
|
-
form = { draft, jsonText, jsonErrors: {} };
|
|
26
|
-
setFormState({ scope, form });
|
|
60
|
+
if (form === null) {
|
|
61
|
+
form = initialForm({ descriptor: port.descriptor, loaded: read.data, id });
|
|
62
|
+
if (form !== null)
|
|
63
|
+
setFormState({ scope, form });
|
|
27
64
|
}
|
|
28
65
|
const draft = form?.draft ?? {};
|
|
29
66
|
const missing = missingRequiredFields({ descriptor: port.descriptor, draft });
|
|
30
67
|
const invalid = invalidDraftFields({ descriptor: port.descriptor, draft });
|
|
31
68
|
const pending = mutation.scope === scope ? mutation.kind : null;
|
|
32
|
-
const canSave = form
|
|
33
|
-
!Object.values(form.jsonErrors).some(Boolean) && pending === null;
|
|
69
|
+
const canSave = canSaveForm({ form, missing, invalid, pending });
|
|
34
70
|
function setField({ name, value }) {
|
|
35
71
|
if (!port.descriptor.fields.some((field) => field.name === name) || busy.current)
|
|
36
72
|
return;
|
|
@@ -39,14 +75,7 @@ export function useEntityEdit({ port, ...props }) {
|
|
|
39
75
|
function setJsonText({ name, text }) {
|
|
40
76
|
if (!port.descriptor.fields.some((field) => field.name === name && field.kind === 'json') || busy.current)
|
|
41
77
|
return;
|
|
42
|
-
|
|
43
|
-
let failed = false;
|
|
44
|
-
try {
|
|
45
|
-
value = text.trim() === '' ? undefined : JSON.parse(text);
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
failed = true;
|
|
49
|
-
}
|
|
78
|
+
const { value, failed } = parseJsonText(text);
|
|
50
79
|
setFormState((state) => state?.scope === scope ? {
|
|
51
80
|
scope,
|
|
52
81
|
form: {
|
|
@@ -57,25 +86,15 @@ export function useEntityEdit({ port, ...props }) {
|
|
|
57
86
|
} : state);
|
|
58
87
|
}
|
|
59
88
|
async function mutate({ kind }) {
|
|
60
|
-
if (busy.current || (kind
|
|
89
|
+
if (busy.current || !mutationAllowed({ kind, canSave, id, port }))
|
|
61
90
|
return;
|
|
62
91
|
busy.current = true;
|
|
63
92
|
setMutation({ scope, kind, failed: false });
|
|
64
93
|
const active = () => mounted.current && currentScope.current === scope;
|
|
65
94
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
routes.navigate({ entity: entityName });
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
// A create omits blanks; an update carries explicit undefined to clear optional fields.
|
|
73
|
-
const saved = id === null
|
|
74
|
-
? await port.create(Object.fromEntries(Object.entries(draft).filter(([, value]) => value !== undefined)))
|
|
75
|
-
: await port.update(id, draft);
|
|
76
|
-
if (active())
|
|
77
|
-
routes.navigate({ entity: entityName, id: saved.id });
|
|
78
|
-
}
|
|
95
|
+
const savedId = await performMutation({ kind, port, id, draft });
|
|
96
|
+
if (active())
|
|
97
|
+
routes.navigate(savedId === null ? { entity: entityName } : { entity: entityName, id: savedId });
|
|
79
98
|
if (active())
|
|
80
99
|
setMutation({ scope, kind: null, failed: false });
|
|
81
100
|
}
|
|
@@ -5,6 +5,40 @@ import { useShellDrawer, useShellRoute } from './AdminShell.hooks.js';
|
|
|
5
5
|
import { resolveAdminShellModel } from './model.js';
|
|
6
6
|
import { useAdminShellSession } from './use-admin-shell-session.js';
|
|
7
7
|
import { useAdminTheme } from './use-admin-theme.js';
|
|
8
|
+
/** Copy only the theme props the host supplied, so absent ones keep the hook's own defaults. */
|
|
9
|
+
function themeOptions(props) {
|
|
10
|
+
return {
|
|
11
|
+
...(props.theme ? { theme: props.theme } : {}),
|
|
12
|
+
...(props.colorScheme ? { colorScheme: props.colorScheme } : {}),
|
|
13
|
+
...(props.themeEnvironment ? { environment: props.themeEnvironment } : {}),
|
|
14
|
+
...(props.themePreferenceStore ? { preferenceStore: props.themePreferenceStore } : {}),
|
|
15
|
+
...(props.onColorSchemeChange ? { onColorSchemeChange: props.onColorSchemeChange } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Loading, login or session-error UI for every state except an authenticated one. */
|
|
19
|
+
function renderSessionGate({ props, context, state, refresh }) {
|
|
20
|
+
if (state.status === 'checking')
|
|
21
|
+
return _jsx("div", { className: "boot-screen", role: "status", children: props.labels.loading });
|
|
22
|
+
if (state.status === 'anonymous')
|
|
23
|
+
return props.slots.login({ context, refreshSession: refresh });
|
|
24
|
+
if (props.slots.sessionError)
|
|
25
|
+
return props.slots.sessionError({ error: state.error, retry: refresh });
|
|
26
|
+
return _jsxs("div", { role: "alert", children: [_jsx("p", { children: props.labels.sessionError }), _jsx("button", { type: "button", onClick: () => { void refresh(); }, children: props.labels.retry })] });
|
|
27
|
+
}
|
|
28
|
+
/** Resolve the authenticated route, nav and slot arguments from the session's contributions. */
|
|
29
|
+
function resolveShellView({ props, context, session, routePath, controller }, { appearance }) {
|
|
30
|
+
const model = resolveAdminShellModel({ panels: props.panels, session, routePath, defaultPanelId: props.defaultPanelId }, { capabilities: props.capabilities ?? [] });
|
|
31
|
+
const slotArgs = {
|
|
32
|
+
context, session, route: model.route, logout: controller.logout, refreshSession: controller.refresh,
|
|
33
|
+
...(appearance ? { appearance } : {}),
|
|
34
|
+
};
|
|
35
|
+
const nav = props.translateNav ? props.translateNav({ groups: model.nav }) : model.nav;
|
|
36
|
+
return { model, slotArgs, nav };
|
|
37
|
+
}
|
|
38
|
+
/** The manifest-driven sidebar: header slot, nav groups, rail toggle and footer slot. */
|
|
39
|
+
function ShellSidebar({ props, view, open, id }) {
|
|
40
|
+
return (_jsxs(Sidebar, { activeId: view.model.route.panelId, base: props.adminBase, open: open, id: id, label: props.labels.navigation, railStorageKey: props.railStorageKey, railDefaultCollapsed: props.railDefaultCollapsed ?? false, children: [props.slots.sidebarHeader, _jsx(Sidebar.Nav, { groups: view.nav, soonLabel: props.labels.soon, collapsibleGroups: props.collapsibleGroups ?? [] }), _jsxs(Sidebar.Footer, { children: [_jsx(Sidebar.RailToggle, { expandLabel: props.labels.expandSidebar, collapseLabel: props.labels.collapseSidebar }), props.slots.sidebarFooter?.(view.slotArgs)] })] }));
|
|
41
|
+
}
|
|
8
42
|
/**
|
|
9
43
|
* A host-composed admin layout with manifest navigation and a session gate.
|
|
10
44
|
* @param props Required host scope, ports, contributions, labels and slots; optional layout settings.
|
|
@@ -13,34 +47,17 @@ import { useAdminTheme } from './use-admin-theme.js';
|
|
|
13
47
|
export function AdminShell(props) {
|
|
14
48
|
const context = useMemo(() => ({ apiBase: props.apiBase, workspace: props.workspace }), [props.apiBase, props.workspace]);
|
|
15
49
|
const session = useAdminShellSession({ port: props.session, context });
|
|
16
|
-
const appearance = useAdminTheme({ userId: session.state.status === 'authenticated' ? session.state.session.user.id : null, workspace: context.workspace },
|
|
17
|
-
...(props.theme ? { theme: props.theme } : {}),
|
|
18
|
-
...(props.colorScheme ? { colorScheme: props.colorScheme } : {}),
|
|
19
|
-
...(props.themeEnvironment ? { environment: props.themeEnvironment } : {}),
|
|
20
|
-
...(props.themePreferenceStore ? { preferenceStore: props.themePreferenceStore } : {}),
|
|
21
|
-
...(props.onColorSchemeChange ? { onColorSchemeChange: props.onColorSchemeChange } : {}),
|
|
22
|
-
});
|
|
50
|
+
const appearance = useAdminTheme({ userId: session.state.status === 'authenticated' ? session.state.session.user.id : null, workspace: context.workspace }, themeOptions(props));
|
|
23
51
|
const routePath = useShellRoute({ navigation: props.navigation, base: props.adminBase });
|
|
24
52
|
const drawer = useShellDrawer({ routePath });
|
|
25
53
|
const id = useId();
|
|
26
54
|
const sidebarId = `${id}-sidebar`;
|
|
27
55
|
const mainId = `${id}-main`;
|
|
28
|
-
if (session.state.status
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (session.state.status === 'error') {
|
|
33
|
-
if (props.slots.sessionError)
|
|
34
|
-
return props.slots.sessionError({ error: session.state.error, retry: session.refresh });
|
|
35
|
-
return _jsxs("div", { role: "alert", children: [_jsx("p", { children: props.labels.sessionError }), _jsx("button", { type: "button", onClick: () => { void session.refresh(); }, children: props.labels.retry })] });
|
|
36
|
-
}
|
|
37
|
-
const model = resolveAdminShellModel({ panels: props.panels, session: session.state.session, routePath, defaultPanelId: props.defaultPanelId }, { capabilities: props.capabilities ?? [] });
|
|
38
|
-
const slotArgs = {
|
|
39
|
-
context, session: session.state.session, route: model.route, logout: session.logout, refreshSession: session.refresh,
|
|
40
|
-
...(appearance ? { appearance } : {}),
|
|
41
|
-
};
|
|
42
|
-
const nav = props.translateNav ? props.translateNav({ groups: model.nav }) : model.nav;
|
|
56
|
+
if (session.state.status !== 'authenticated')
|
|
57
|
+
return renderSessionGate({ props, context, state: session.state, refresh: session.refresh });
|
|
58
|
+
const view = resolveShellView({ props, context, session: session.state.session, routePath, controller: session }, { appearance });
|
|
59
|
+
const { model, slotArgs } = view;
|
|
43
60
|
const assistant = props.slots.assistant;
|
|
44
|
-
return (_jsxs("div", { className: ['admin-layout', props.className].filter(Boolean).join(' '), children: [_jsx("a", { href: `#${mainId}`, className: "skip-link", children: props.labels.skipToContent }),
|
|
61
|
+
return (_jsxs("div", { className: ['admin-layout', props.className].filter(Boolean).join(' '), children: [_jsx("a", { href: `#${mainId}`, className: "skip-link", children: props.labels.skipToContent }), _jsx(ShellSidebar, { props: props, view: view, open: drawer.open, id: sidebarId }), drawer.open && _jsx("button", { type: "button", className: "sidebar-backdrop", "aria-label": props.labels.closeNavigation, onClick: () => drawer.setOpen(false) }), _jsxs("div", { className: "admin-main-col", children: [_jsxs("div", { className: "admin-topbar", children: [_jsx("button", { type: "button", className: "admin-topbar-toggle", "aria-expanded": drawer.open, "aria-controls": sidebarId, "aria-label": drawer.open ? props.labels.closeNavigation : props.labels.openNavigation, onClick: () => drawer.setOpen((open) => !open), children: _jsx("svg", { viewBox: "0 0 18 18", fill: "none", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { d: "M2.5 5h13M2.5 9h13M2.5 13h13" }) }) }), _jsx("span", { className: "admin-topbar-title", children: props.title }), props.slots.topbar] }), _jsx("main", { ref: props.mainRef, id: mainId, className: "admin-content", tabIndex: -1, "data-agent-page": model.agentPageId ?? undefined, children: model.panel?.render(slotArgs) })] }), assistant && _jsx("aside", { className: "admin-assistant-pane", "aria-label": assistant.label, hidden: !assistant.open, children: assistant.content }), props.slots.overlays?.(slotArgs)] }));
|
|
45
62
|
}
|
|
46
63
|
//# sourceMappingURL=AdminShell.js.map
|