@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.
Files changed (151) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
  3. package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
  4. package/dist/agent-plugins/package-file-tree.js +54 -51
  5. package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
  6. package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
  7. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
  8. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
  9. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
  10. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
  11. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
  12. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
  13. package/dist/browser/navigation.js +44 -27
  14. package/dist/comments/adapters/memory.js +30 -12
  15. package/dist/comments/conformance/comments-api.conformance.js +55 -33
  16. package/dist/comments/controllers/comment-queue.controller.js +13 -4
  17. package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
  18. package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
  19. package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
  20. package/dist/comments/react/index.d.ts +2 -1
  21. package/dist/comments/react/index.js +1 -1
  22. package/dist/consent/react/ConsentFields.d.ts +35 -0
  23. package/dist/consent/react/ConsentFields.js +74 -0
  24. package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
  25. package/dist/consent/react/ConsentPreviewFrame.js +14 -0
  26. package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
  27. package/dist/consent/react/ConsentSettingsPanel.js +6 -17
  28. package/dist/consent/react/index.d.ts +6 -1
  29. package/dist/consent/react/index.js +2 -0
  30. package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
  31. package/dist/consent/react/settings-panel.hooks.js +26 -0
  32. package/dist/core/entities/rules.js +126 -81
  33. package/dist/core/manifest/rules.js +10 -10
  34. package/dist/core/module/create-admin.js +195 -117
  35. package/dist/core/ports/redirects.d.ts +8 -6
  36. package/dist/core/ports/redirects.js +8 -6
  37. package/dist/core/routing/rules.js +9 -4
  38. package/dist/core/transport/http.js +37 -20
  39. package/dist/forms/adapters/memory.js +22 -11
  40. package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
  41. package/dist/forms/conformance/forms-api.conformance.js +42 -46
  42. package/dist/forms/field-settings.js +150 -115
  43. package/dist/forms/html-rules.js +45 -28
  44. package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
  45. package/dist/forms/react/components/FieldSettingsRows.js +29 -0
  46. package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
  47. package/dist/forms/react/components/FieldSettingsSection.js +5 -6
  48. package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
  49. package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
  50. package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
  51. package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
  52. package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
  53. package/dist/forms/react/components/FormSubmissions.js +4 -20
  54. package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
  55. package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
  56. package/dist/media/adapters/memory.js +31 -26
  57. package/dist/media/controllers/edit-media.controller.js +2 -1
  58. package/dist/media/controllers/library.controller.js +48 -35
  59. package/dist/media/controllers/providers.controller.js +2 -1
  60. package/dist/media/html-attributes.js +29 -15
  61. package/dist/media/index.d.ts +1 -1
  62. package/dist/media/index.js +1 -1
  63. package/dist/media/react/components/MediaIcons.d.ts +0 -3
  64. package/dist/media/react/components/MediaIcons.js +0 -9
  65. package/dist/media/react/components/MediaLightbox.d.ts +2 -1
  66. package/dist/media/react/components/MediaLightbox.js +11 -5
  67. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
  68. package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
  69. package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
  70. package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
  71. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
  72. package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
  73. package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
  74. package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
  75. package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
  76. package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
  77. package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
  78. package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
  79. package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
  80. package/dist/media/react/index.js +4 -0
  81. package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
  82. package/dist/media/react/tabs/LibraryTab.js +2 -2
  83. package/dist/media/rules.d.ts +7 -0
  84. package/dist/media/rules.js +25 -2
  85. package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
  86. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
  87. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
  88. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
  89. package/dist/react/components/RowMenu/RowMenu.js +16 -10
  90. package/dist/react/components/Sidebar.js +32 -26
  91. package/dist/react/entities/EntityEdit.js +17 -14
  92. package/dist/react/entities/EntityField.d.ts +5 -2
  93. package/dist/react/entities/EntityField.js +38 -23
  94. package/dist/react/entities/EntityList.js +6 -5
  95. package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
  96. package/dist/react/entities/RelationTruncationNotice.js +9 -0
  97. package/dist/react/entities/use-entity-edit.d.ts +4 -2
  98. package/dist/react/entities/use-entity-edit.js +52 -33
  99. package/dist/react/shell/AdminShell.js +40 -23
  100. package/dist/react/shell/model.d.ts +4 -1
  101. package/dist/react/shell/model.js +3 -2
  102. package/dist/react/shell/use-admin-shell-session.js +25 -26
  103. package/dist/redirects/adapters/memory.js +9 -2
  104. package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
  105. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
  106. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
  107. package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
  108. package/dist/security/adapters/http.js +25 -10
  109. package/dist/security/adapters/memory.js +19 -5
  110. package/dist/security/controllers/access-tokens.controller.js +27 -26
  111. package/dist/security/controllers/other-credentials.controller.js +4 -2
  112. package/dist/security/controllers/root-key.controller.js +17 -9
  113. package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
  114. package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
  115. package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
  116. package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
  117. package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
  118. package/dist/security/react/tabs/RootKeyTab.js +12 -3
  119. package/dist/security/rules.js +45 -25
  120. package/dist/seo/controllers/seo.controller.js +4 -2
  121. package/dist/seo/react/components/MediaRefField.d.ts +1 -1
  122. package/dist/seo/react/components/MediaRefField.js +19 -2
  123. package/dist/seo/react/hooks/MediaRefField.hooks.js +3 -1
  124. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -1
  125. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -34
  126. package/dist/seo/react/pages/SeoPage.d.ts +4 -4
  127. package/dist/seo/react/pages/SeoPage.js +26 -6
  128. package/dist/seo/rules.js +39 -10
  129. package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
  130. package/dist/source-control/controllers/source-control.controller.js +20 -8
  131. package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
  132. package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
  133. package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
  134. package/dist/widgets/controllers/instance-editor.controller.js +33 -24
  135. package/dist/widgets/controllers/library.controller.js +18 -12
  136. package/dist/widgets/controllers/region-editor.controller.js +6 -5
  137. package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
  138. package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
  139. package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
  140. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
  141. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
  142. package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
  143. package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
  144. package/dist/widgets/rules.d.ts +14 -2
  145. package/dist/widgets/rules.js +9 -6
  146. package/package.json +17 -8
  147. package/src/themes/default/admin.css +51 -0
  148. package/src/themes/default/chat.css +21 -0
  149. package/src/themes/default/manifest.json +29 -0
  150. package/dist/media/styles.d.ts +0 -4
  151. 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 raw = el.getAttribute('data-embed-config');
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
- const type = config.type.toLowerCase();
144
- if (type === 'gallery') {
145
- const count = Array.isArray(config.images) ? config.images.length : 0;
146
- return { kindLabel: EMBED_KIND_LABELS.gallery, identityLabel: count === 0 ? 'no images set' : `${count} ${count === 1 ? 'image' : 'images'}` };
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: `row-menu-popup${position?.placement === 'above' ? ' row-menu-popup-above' : ''}`, style: {
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 panelId = `cms-section-${(group.label ?? `top-${gi}`).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
214
- return (_jsxs("div", { className: "cms-section", children: [group.label && collapsible ? (
215
- // A real `<button>`, not a click-handled `<div>`: keyboard focus, Enter/Space
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, jsxs as _jsxs } from "react/jsx-runtime";
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
- import { RELATION_OPTION_LIMIT } from './rules.js';
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
- if (id !== null && edit.read.data.row === null)
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
- if (!edit.form)
18
- return _jsx("p", { role: "status", children: translate({ key: 'Loading' }) });
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
- export declare function EntityField(props: {
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
- }): import("react").JSX.Element;
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 { inputId, field, value, translate, disabled, invalid, onChange } = props;
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", { ...common, value: props.text, onChange: (e) => props.onTextChange({ text: e.target.value }) });
11
- case 'relation': {
12
- const options = props.relations[field.target]?.options ?? [];
13
- const selectedId = typeof value === 'string' ? value : '';
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("input", { ...common, 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) }) });
30
- case 'text': return _jsx("input", { ...common, value: typeof value === 'string' ? value : '', onChange: (e) => onChange({ value: e.target.value === '' ? undefined : e.target.value }) });
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, RELATION_OPTION_LIMIT } from './rules.js';
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
- if (walk.token !== token || walk.port !== port)
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 = walk.token === token && walk.port === port ? walk.entries : [{ cursor: null, offset: 0 }];
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' }) }), 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] });
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: import("./use-entity-read.js").EntityLoadedRow | undefined;
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: "save" | "remove" | null;
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 (read.data && form === null && (id === null || read.data.row !== null)) {
19
- const draft = draftForRow({ descriptor: port.descriptor, row: read.data.row });
20
- const jsonText = Object.create(null);
21
- for (const field of port.descriptor.fields) {
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 !== null && missing.length === 0 && invalid.length === 0 &&
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
- let value;
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 === 'save' && !canSave) || (kind === 'remove' && (id === null || port.remove === undefined)))
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
- if (kind === 'remove' && id !== null && port.remove) {
67
- await port.remove(id);
68
- if (active())
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 === 'checking')
29
- return _jsx("div", { className: "boot-screen", role: "status", children: props.labels.loading });
30
- if (session.state.status === 'anonymous')
31
- return props.slots.login({ context, refreshSession: session.refresh });
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 }), _jsxs(Sidebar, { activeId: model.route.panelId ?? '', base: props.adminBase, open: drawer.open, id: sidebarId, label: props.labels.navigation, railStorageKey: props.railStorageKey, railDefaultCollapsed: props.railDefaultCollapsed ?? false, children: [props.slots.sidebarHeader, _jsx(Sidebar.Nav, { groups: 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?.(slotArgs)] })] }), 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)] }));
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