@jini-ai/admin 0.4.12 → 0.6.10

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 +70 -2
  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 +76 -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 +23 -14
  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
@@ -1,4 +1,4 @@
1
1
  import type { TabViewProps } from '../../../core/react/bind-react.js';
2
- export declare function LibraryTab(props: TabViewProps, _optional?: Record<string, never>): import("react").JSX.Element;
2
+ export declare function LibraryTab(props: TabViewProps): import("react").JSX.Element;
3
3
  export default LibraryTab;
4
4
  //# sourceMappingURL=LibraryTab.d.ts.map
@@ -6,9 +6,9 @@ import { UploadButton } from '../components/UploadButton.js';
6
6
  import { EditMediaPanel } from '../components/EditMediaPanel.js';
7
7
  import { MediaLightbox } from '../components/MediaLightbox.js';
8
8
  import { MediaPurgeDialog } from '../components/MediaPurgeDialog.js';
9
- export function LibraryTab(props, _optional = {}) {
9
+ export function LibraryTab(props) {
10
10
  const vm = useLibraryTab(props);
11
- return (_jsxs("section", { className: "jini-media-library", "data-jini-part": "media.library", children: [vm.canUpload && _jsx(UploadButton, { upload: vm.upload, disabled: vm.busy }), _jsx("div", { className: "jini-media-order-control", children: _jsx(Select, { label: "Order by", attrs: { 'data-jini-part': 'media.sort', 'data-agent-element': 'media-order-by' }, value: vm.orderBy, onValueChange: vm.onOrder, options: [{ value: 'created', label: 'Created (newest first)' }, { value: 'alphabetical', label: 'Alphabetical (A–Z)' }] }) }), _jsxs("div", { className: "jini-media-library-filters", hidden: true, children: [_jsx(TextField, { attrs: { 'data-jini-part': 'media.search', 'data-agent-element': 'media-search' }, label: "Search media", value: vm.search, onValueChange: vm.onSearch }), _jsx(Select, { label: "Status", value: vm.statusFilter, onValueChange: vm.onStatus, options: [{ value: 'all', label: 'All statuses' }, { value: 'active', label: 'Active' }, { value: 'trashed', label: 'Trash' }] })] }), _jsx("p", { className: "jini-media-success", role: "status", "aria-live": "polite", "aria-atomic": "true", children: vm.success }), vm.untyped && _jsx("p", { className: "jini-notice", children: vm.t.untyped }), vm.snapshot?.error && _jsxs("div", { className: "jini-notice jini-notice-error", children: [_jsx("p", { className: "jini-notice-text", role: "alert", children: vm.snapshot.error }), _jsx(Button, { onPress: vm.onRetry, children: "Retry" })] }), vm.loading ? (_jsx("p", { className: "jini-notice", role: "status", children: vm.t.loading })) : (_jsx(MediaGrid, { gridRef: vm.gridRef, emptyLabel: vm.emptyLabel, actions: vm.actions, api: vm.mediaApi })), _jsx(MediaLightbox, { items: vm.items, api: vm.mediaApi, activeIndex: vm.lightboxIndex, onNavigate: vm.onNavigate, onClose: vm.closeLightbox }), vm.editing && (_jsx(EditMediaPanel, { api: vm.mediaApi, item: vm.editing, onSaved: vm.saved, onClose: vm.closeEdit }, vm.editing.id)), _jsx(MediaPurgeDialog, { open: !!vm.snapshot?.pendingPurge, pending: vm.busy, ...(vm.snapshot?.pendingPurge ? { filename: vm.snapshot.pendingPurge.title } : {}), onCancel: vm.cancelPurge, onConfirm: vm.confirmPurge })] }));
11
+ return (_jsxs("section", { className: "jini-media-library", "data-jini-part": "media.library", children: [vm.canUpload && _jsx(UploadButton, { upload: vm.upload, disabled: vm.busy }), _jsx("div", { className: "jini-media-order-control", children: _jsx(Select, { label: "Order by", attrs: { 'data-jini-part': 'media.sort', 'data-agent-element': 'media-order-by' }, value: vm.orderBy, onValueChange: vm.onOrder, options: [{ value: 'created', label: 'Created (newest first)' }, { value: 'alphabetical', label: 'Alphabetical (A–Z)' }] }) }), _jsxs("div", { className: "jini-media-library-filters", hidden: true, children: [_jsx(TextField, { attrs: { 'data-jini-part': 'media.search', 'data-agent-element': 'media-search' }, label: "Search media", value: vm.search, onValueChange: vm.onSearch }), _jsx(Select, { label: "Status", value: vm.statusFilter, onValueChange: vm.onStatus, options: [{ value: 'all', label: 'All statuses' }, { value: 'active', label: 'Active' }, { value: 'trashed', label: 'Trash' }] })] }), _jsx("p", { className: "jini-media-success", role: "status", "aria-live": "polite", "aria-atomic": "true", children: vm.success }), vm.untyped && _jsx("p", { className: "jini-notice", children: vm.t.untyped }), vm.snapshot?.error && _jsxs("div", { className: "jini-notice jini-notice-error", children: [_jsx("p", { className: "jini-notice-text", role: "alert", children: vm.snapshot.error }), _jsx(Button, { onPress: vm.onRetry, children: "Retry" })] }), vm.loading ? (_jsx("p", { className: "jini-notice", role: "status", children: vm.t.loading })) : (_jsx(MediaGrid, { gridRef: vm.gridRef, emptyLabel: vm.emptyLabel, actions: vm.actions, api: vm.mediaApi })), _jsx(MediaLightbox, { items: vm.items, api: vm.mediaApi, activeIndex: vm.lightboxIndex, onNavigate: vm.onNavigate, onClose: vm.closeLightbox }), vm.editing && (_jsx(EditMediaPanel, { api: vm.mediaApi, item: vm.editing, onSaved: vm.saved, onClose: vm.closeEdit }, vm.editing.id)), _jsx(MediaPurgeDialog, { ...vm.purgeDialog, onCancel: vm.cancelPurge, onConfirm: vm.confirmPurge })] }));
12
12
  }
13
13
  export default LibraryTab;
14
14
  //# sourceMappingURL=LibraryTab.js.map
@@ -56,6 +56,13 @@ export declare function formatByteSize({ bytes }: {
56
56
  }, { locale }?: {
57
57
  locale?: string;
58
58
  }): string;
59
+ /** The file name a media card shows. A media record keeps only its extension-less title, so the
60
+ * extension comes from the type sniffed from its bytes; an unknown type, or a title that already
61
+ * ends with that extension, shows the title as-is. */
62
+ export declare function mediaFileName({ title, contentType }: {
63
+ title: string;
64
+ contentType: string | null;
65
+ }, _optional?: Record<string, never>): string;
59
66
  /** Full-library counts, independent of search/order/type selection; Providers has no count. */
60
67
  export declare function countMediaTabs({ media }: {
61
68
  media: readonly MediaAsset[];
@@ -75,8 +75,16 @@ export function isRetryableMediaError({ error }, _optional = {}) {
75
75
  const failure = error;
76
76
  if (typeof failure.retryable === 'boolean')
77
77
  return failure.retryable;
78
- const status = failure.status ?? failure.statusCode ?? Number(failure.message?.match(/HTTP\s+(\d{3})/i)?.[1]);
79
- return status === 408 || status === 429 || (status >= 500 && status <= 599) || error instanceof TypeError;
78
+ return isRetryableHttpStatus({ status: mediaErrorHttpStatus({ failure }) }) || error instanceof TypeError;
79
+ }
80
+ /** `status`, else `statusCode`, else the `HTTP nnn` an older transport put in its message —
81
+ * `NaN` when none is present. */
82
+ function mediaErrorHttpStatus({ failure }, _optional = {}) {
83
+ return failure.status ?? failure.statusCode ?? Number(failure.message?.match(/HTTP\s+(\d{3})/i)?.[1]);
84
+ }
85
+ /** Request timeout, rate limiting and any 5xx are temporary; `NaN` and every other status are not. */
86
+ function isRetryableHttpStatus({ status }, _optional = {}) {
87
+ return status === 408 || status === 429 || (status >= 500 && status <= 599);
80
88
  }
81
89
  /** Blank means native size; invalid values reach the server's positive-integer validator. */
82
90
  export function parseOptionalPixelSize({ value }, _optional = {}) {
@@ -94,6 +102,21 @@ export function formatByteSize({ bytes }, { locale = 'en-US' } = {}) {
94
102
  const mb = kb / 1024;
95
103
  return mb < 1024 ? `${decimal.format(mb)} MB` : `${decimal.format(mb / 1024)} GB`;
96
104
  }
105
+ /** The extension each sniffed media type is stored as; JPEG shows as the common ".jpg". */
106
+ const MEDIA_EXTENSION_BY_CONTENT_TYPE = {
107
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif',
108
+ 'image/svg+xml': 'svg', 'video/mp4': 'mp4', 'video/webm': 'webm', 'audio/wav': 'wav', 'audio/mpeg': 'mp3',
109
+ 'audio/flac': 'flac', 'audio/mp4': 'm4a', 'application/pdf': 'pdf',
110
+ };
111
+ /** The file name a media card shows. A media record keeps only its extension-less title, so the
112
+ * extension comes from the type sniffed from its bytes; an unknown type, or a title that already
113
+ * ends with that extension, shows the title as-is. */
114
+ export function mediaFileName({ title, contentType }, _optional = {}) {
115
+ const extension = contentType ? MEDIA_EXTENSION_BY_CONTENT_TYPE[contentType] : undefined;
116
+ if (!extension || title.toLowerCase().endsWith(`.${extension}`))
117
+ return title;
118
+ return `${title}.${extension}`;
119
+ }
97
120
  /** Full-library counts, independent of search/order/type selection; Providers has no count. */
98
121
  export function countMediaTabs({ media }, _optional = {}) {
99
122
  return { all: media.length, images: filterMediaByTab({ media, tab: 'images' }).length,
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { agentHandle } from '@jini-ai/agentic';
3
2
  import { useConfirmController } from '@jini-ai/ui-kit/react';
4
3
  import { resolveTone, toneClassName } from '../../types.js';
5
4
  import { useConfirmDialog, useConfirmDialogCancelLabel } from './ConfirmDialog.hooks.js';
5
+ import { confirmDialogAgentAttrs, withoutAccessibleName } from './confirm-dialog-attrs.js';
6
6
  /**
7
7
  * UI-kit's confirmation planner owns the sub-handle segments appended to the caller's base:
8
8
  * literals chosen by the mechanism, never host data. Neither needs sanitizing, unlike
@@ -39,16 +39,13 @@ export function ConfirmDialog({ useDialog = useConfirmDialog, agentHandle: baseH
39
39
  ...(props.pending === undefined ? {} : { pending: props.pending }),
40
40
  onConfirm: () => props.onConfirm(), onCancel: () => props.onCancel(),
41
41
  }, {
42
- // UI-kit names selects separately; agentic publishes them as fields and still owns handle validation.
43
- agent: ((required, optional = {}) => agentHandle(required, {
44
- ...(optional.role === undefined ? {} : { role: optional.role === 'select' ? 'field' : optional.role }),
45
- ...(optional.label === undefined ? {} : { label: optional.label }),
46
- })),
42
+ // UI-kit names selects separately; see `confirmDialogAgentAttrs`.
43
+ agent: confirmDialogAgentAttrs,
47
44
  });
48
45
  const { titleId, dialogRef, cancelRef, handleNativeCancel, handleBackdropClick } = useDialog({ open: props.open, onCancel: () => { controller.requestDismiss({ reason: 'close' }); }, document }, { pending: controller.pending });
49
46
  // Keep admin's concise accessible names; the shared planner still supplies consequence-aware agent labels.
50
- const { 'aria-label': _cancelName, ...cancelAttrs } = controller.cancel.attrs ?? {};
51
- const { 'aria-label': _confirmName, ...confirmAttrs } = controller.confirm.attrs ?? {};
47
+ const cancelAttrs = withoutAccessibleName({ attrs: controller.cancel.attrs });
48
+ const confirmAttrs = withoutAccessibleName({ attrs: controller.confirm.attrs });
52
49
  return (_jsxs("dialog", { ref: dialogRef, className: "confirm-dialog", "aria-labelledby": titleId, onCancel: handleNativeCancel, onClick: handleBackdropClick, children: [_jsx("h2", { id: titleId, children: props.title }), _jsxs("div", { className: "confirm-dialog-body", children: [props.body, controller.error] }), _jsxs("div", { className: "confirm-dialog-actions", children: [_jsx("button", { ref: cancelRef, type: "button", className: "btn-secondary", disabled: controller.pending, onClick: () => controller.cancel.onPress?.({}), ...cancelAttrs, children: cancelLabel }), _jsx("button", { type: "button", className: toneClassName({ tone }), disabled: controller.pending, onClick: () => controller.confirm.onPress?.({}), ...confirmAttrs, children: props.confirmLabel })] })] }));
53
50
  }
54
51
  //# sourceMappingURL=ConfirmDialog.js.map
@@ -0,0 +1,20 @@
1
+ import type { AgentAttrsPort, KitAttrs } from '@jini-ai/ui-kit';
2
+ /**
3
+ * The agent-attribute port `ConfirmDialog` hands UI-kit's confirm controller. UI-kit names selects
4
+ * separately; agentic publishes them as fields and still owns handle validation. Absent role/label
5
+ * options stay absent rather than being sent as `undefined`.
6
+ *
7
+ * @complexity O(1).
8
+ */
9
+ export declare const confirmDialogAgentAttrs: AgentAttrsPort;
10
+ /**
11
+ * Keep admin's concise accessible names; the shared planner still supplies consequence-aware agent
12
+ * labels. Returns the action's attributes without UI-kit's `aria-label`. UI-kit's `ButtonProps`
13
+ * types `attrs` as optional, so a missing set yields an empty one.
14
+ *
15
+ * @complexity O(k) in the number of attributes.
16
+ */
17
+ export declare function withoutAccessibleName({ attrs }: {
18
+ attrs: KitAttrs | undefined;
19
+ }, _optional?: Record<string, never>): KitAttrs;
20
+ //# sourceMappingURL=confirm-dialog-attrs.d.ts.map
@@ -0,0 +1,24 @@
1
+ import { agentHandle } from '@jini-ai/agentic';
2
+ /**
3
+ * The agent-attribute port `ConfirmDialog` hands UI-kit's confirm controller. UI-kit names selects
4
+ * separately; agentic publishes them as fields and still owns handle validation. Absent role/label
5
+ * options stay absent rather than being sent as `undefined`.
6
+ *
7
+ * @complexity O(1).
8
+ */
9
+ export const confirmDialogAgentAttrs = (required, optional = {}) => agentHandle(required, {
10
+ ...(optional.role === undefined ? {} : { role: optional.role === 'select' ? 'field' : optional.role }),
11
+ ...(optional.label === undefined ? {} : { label: optional.label }),
12
+ });
13
+ /**
14
+ * Keep admin's concise accessible names; the shared planner still supplies consequence-aware agent
15
+ * labels. Returns the action's attributes without UI-kit's `aria-label`. UI-kit's `ButtonProps`
16
+ * types `attrs` as optional, so a missing set yields an empty one.
17
+ *
18
+ * @complexity O(k) in the number of attributes.
19
+ */
20
+ export function withoutAccessibleName({ attrs }, _optional = {}) {
21
+ const { 'aria-label': _accessibleName, ...rest } = attrs ?? {};
22
+ return rest;
23
+ }
24
+ //# sourceMappingURL=confirm-dialog-attrs.js.map
@@ -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;