@jini-ai/admin 0.6.15-rc.1 → 0.6.15

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 (104) hide show
  1. package/dist/agent-plugins/adapters/memory.js +9 -3
  2. package/dist/agent-plugins/conformance/agent-plugins-api.conformance.js +1 -1
  3. package/dist/agent-plugins/controllers/agent-plugins.controller.js +14 -5
  4. package/dist/agent-plugins/package-file-tree.js +4 -6
  5. package/dist/agent-plugins/react/components/AgentPluginInspector.js +5 -2
  6. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +10 -4
  7. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +8 -5
  8. package/dist/agent-plugins/rules.js +3 -1
  9. package/dist/comments/adapters/http.js +2 -1
  10. package/dist/comments/adapters/memory.js +3 -1
  11. package/dist/comments/conformance/comments-api.conformance.js +3 -2
  12. package/dist/comments/controllers/comment-queue.controller.d.ts +1 -1
  13. package/dist/comments/controllers/comment-queue.controller.js +4 -2
  14. package/dist/core/entities/rules.js +13 -20
  15. package/dist/core/module/create-admin.js +2 -1
  16. package/dist/core/omit-keys.d.ts +7 -0
  17. package/dist/core/omit-keys.js +12 -0
  18. package/dist/core/ports/forms.d.ts +5 -3
  19. package/dist/core/transport/query-suffix.d.ts +3 -0
  20. package/dist/core/transport/query-suffix.js +5 -0
  21. package/dist/forms/adapters/http.js +3 -2
  22. package/dist/forms/adapters/memory.js +3 -2
  23. package/dist/forms/controllers/field-attributes-dialog.controller.js +17 -3
  24. package/dist/forms/controllers/form-fields-editor.controller.js +1 -2
  25. package/dist/forms/controllers/form-submission-detail.controller.js +1 -2
  26. package/dist/forms/controllers/form-submissions.controller.js +1 -2
  27. package/dist/forms/controllers/forms-list.controller.js +1 -2
  28. package/dist/forms/field-settings.js +15 -4
  29. package/dist/forms/html-rules.js +10 -13
  30. package/dist/forms/react/hooks/forms-view.hooks.d.ts +2 -2
  31. package/dist/forms/react/hooks/forms-view.hooks.js +9 -1
  32. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +1 -3
  33. package/dist/forms/react/hooks/use-form-editor.hooks.js +8 -9
  34. package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -4
  35. package/dist/forms/react/hooks/use-form-submissions.hooks.js +1 -5
  36. package/dist/forms/react/hooks/use-forms-list.hooks.js +1 -2
  37. package/dist/forms/rules.js +7 -2
  38. package/dist/media/adapters/memory.js +5 -3
  39. package/dist/media/controllers/library.controller.js +4 -2
  40. package/dist/media/controllers/providers.controller.js +2 -1
  41. package/dist/media/html-attributes.d.ts +1 -8
  42. package/dist/media/html-attributes.js +6 -161
  43. package/dist/media/react/components/MediaCard.js +11 -1
  44. package/dist/media/react/hooks/EditMediaPanel.hooks.js +18 -7
  45. package/dist/media/react/hooks/LibraryTab.hooks.js +3 -2
  46. package/dist/media/react/hooks/ProvidersTab.hooks.js +19 -9
  47. package/dist/media/rules.js +6 -3
  48. package/dist/playground/adapters/memory.js +26 -7
  49. package/dist/playground/conformance/playground-api.conformance.js +6 -3
  50. package/dist/playground/react/hooks/PlaygroundPorts.hooks.js +6 -2
  51. package/dist/react/components/ConfirmButton/ConfirmButton.hooks.js +1 -1
  52. package/dist/react/components/ConfirmButton/ConfirmButton.js +2 -5
  53. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +2 -2
  54. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +3 -1
  55. package/dist/react/components/RowMenu/RowMenu.hooks.js +4 -8
  56. package/dist/react/components/Sidebar.js +1 -1
  57. package/dist/react/entities/EntityField.js +1 -0
  58. package/dist/react/entities/contribution.js +2 -1
  59. package/dist/react/entities/types.js +1 -1
  60. package/dist/react/overlays.js +2 -1
  61. package/dist/react/shell/AdminShell.hooks.js +1 -1
  62. package/dist/react/shell/use-admin-shell-session.js +2 -1
  63. package/dist/react/shell/use-admin-theme.js +6 -6
  64. package/dist/redirects/adapters/memory.js +16 -13
  65. package/dist/security/adapters/http.js +10 -11
  66. package/dist/security/adapters/memory.js +50 -15
  67. package/dist/security/conformance/security-api.conformance.js +6 -3
  68. package/dist/security/controllers/access-tokens.controller.js +7 -3
  69. package/dist/security/controllers/other-credentials.controller.js +4 -2
  70. package/dist/security/controllers/root-key.controller.js +12 -5
  71. package/dist/security/react/hooks/AccessTokensTab.hooks.js +8 -3
  72. package/dist/security/react/hooks/SecurityPorts.hooks.js +6 -2
  73. package/dist/security/rules.js +19 -5
  74. package/dist/seo/adapters/memory.js +5 -2
  75. package/dist/seo/controllers/seo-entry.controller.js +2 -1
  76. package/dist/seo/controllers/sitemap.controller.js +2 -1
  77. package/dist/seo/react/hooks/MediaPickerBridge.hooks.js +1 -1
  78. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -21
  79. package/dist/seo/react/hooks/SeoPage.hooks.js +0 -8
  80. package/dist/seo/react/hooks/use-seo.hooks.js +1 -1
  81. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -1
  82. package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +9 -1
  83. package/dist/seo/react/pages/SeoPage.js +17 -3
  84. package/dist/source-control/adapters/memory.js +30 -14
  85. package/dist/source-control/controllers/source-control.controller.js +5 -2
  86. package/dist/source-control/react/components/SourceControlProviderRow.js +6 -1
  87. package/dist/source-control/react/hooks/SourceControlPorts.hooks.js +6 -2
  88. package/dist/source-control/rules.js +9 -3
  89. package/dist/widgets/adapters/http.js +2 -1
  90. package/dist/widgets/adapters/memory.js +4 -2
  91. package/dist/widgets/conformance/widgets-api.conformance.js +5 -2
  92. package/dist/widgets/controllers/instance-editor.controller.js +3 -2
  93. package/dist/widgets/controllers/library.controller.js +2 -1
  94. package/dist/widgets/controllers/region-editor.controller.js +2 -1
  95. package/dist/widgets/controllers/regions.controller.js +2 -1
  96. package/dist/widgets/react/components/WidgetInstanceNotices.d.ts +1 -1
  97. package/dist/widgets/react/components/WidgetInstanceNotices.js +2 -0
  98. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +5 -2
  99. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +5 -2
  100. package/dist/widgets/react/pages/WidgetInstanceEditor.js +1 -1
  101. package/dist/widgets/react/pages/WidgetRegionEditor.js +1 -1
  102. package/dist/widgets/react/pages/WidgetsLibrary.js +1 -1
  103. package/dist/widgets/rules.js +5 -1
  104. package/package.json +13 -13
@@ -17,9 +17,15 @@ export function createMemoryAgentPluginsApi({ plugins = [], files = {} }, _optio
17
17
  rows.set(pluginId, updated);
18
18
  return updated;
19
19
  },
20
- async files({ pluginId }, options = {}) { options.signal?.throwIfAborted(); if (!rows.has(pluginId))
21
- return notFound(); const listing = listings.get(pluginId); if (!listing)
22
- return agentPluginFilesSnapshot({ listing: { pluginId, files: [], truncated: false, limits: { maxFiles: 200, maxEntries: 2000, maxFileBytes: 524288, maxTotalBytes: 4194304 } } }); return listing; },
20
+ async files({ pluginId }, options = {}) {
21
+ options.signal?.throwIfAborted();
22
+ if (!rows.has(pluginId))
23
+ return notFound();
24
+ const listing = listings.get(pluginId);
25
+ if (!listing)
26
+ return agentPluginFilesSnapshot({ listing: { pluginId, files: [], truncated: false, limits: { maxFiles: 200, maxEntries: 2000, maxFileBytes: 524288, maxTotalBytes: 4194304 } } });
27
+ return listing;
28
+ },
23
29
  };
24
30
  }
25
31
  //# sourceMappingURL=memory.js.map
@@ -21,7 +21,7 @@ export async function runAgentPluginsApiConformance({ create, pluginId, otherPlu
21
21
  } check(rejected); };
22
22
  try {
23
23
  await test('lists installed packages independent of activation', () => { check(initial.length >= 2 && new Set(initial.map(p => p.pluginId)).size === initial.length); });
24
- await test('list snapshots are immutable', () => { check(Object.isFrozen(initial) && Object.isFrozen(first) && Object.isFrozen(first.skills) && Object.isFrozen(first.keywords) && Object.isFrozen(first.mcpServerIds)); });
24
+ await test('list snapshots are immutable', () => { check([initial, first, first.skills, first.keywords, first.mcpServerIds].every(value => Object.isFrozen(value))); });
25
25
  await test('activation replies with the single updated row', async () => { const row = await api.setEnabled({ pluginId, enabled: !first.enabled }); check(row.pluginId === pluginId && row.enabled === !first.enabled && row.description === first.description); });
26
26
  await test('accepted activation is visible on reload', async () => { check((await api.list({})).find(p => p.pluginId === pluginId)?.enabled === !first.enabled); });
27
27
  await test('old snapshot retains its original enabled value', () => { check(initial.find(p => p.pluginId === pluginId)?.enabled === first.enabled); });
@@ -17,7 +17,8 @@ export function createAgentPluginsController({ api, permissions = [] }, _optiona
17
17
  async function load(_required, _optional = {}) {
18
18
  if (!canRead())
19
19
  return;
20
- const ticket = ++generation, version = revision;
20
+ generation += 1;
21
+ const ticket = generation, version = revision;
21
22
  store.set({ patch: { loading: true, error: null } });
22
23
  try {
23
24
  const plugins = await api.list({}, { signal: store.signal });
@@ -67,8 +68,12 @@ export function createAgentPluginsController({ api, permissions = [] }, _optiona
67
68
  if (canWrite() && !store.getSnapshot().pendingDisable && rowFor(input.pluginId)?.enabled && !store.getSnapshot().busyIds.includes(input.pluginId))
68
69
  store.set({ patch: { pendingDisable: { ...input } } });
69
70
  },
70
- cancelDisable(_required, _optional = {}) { const p = store.getSnapshot().pendingDisable; if (!p || store.getSnapshot().busyIds.includes(p.pluginId))
71
- return; store.set({ patch: { pendingDisable: null } }); },
71
+ cancelDisable(_required, _optional = {}) {
72
+ const p = store.getSnapshot().pendingDisable;
73
+ if (!p || store.getSnapshot().busyIds.includes(p.pluginId))
74
+ return;
75
+ store.set({ patch: { pendingDisable: null } });
76
+ },
72
77
  async confirmDisable(_required, _optional = {}) {
73
78
  const pending = store.getSnapshot().pendingDisable;
74
79
  if (!pending || !canWrite() || !rowFor(pending.pluginId)?.enabled) {
@@ -83,8 +88,12 @@ export function createAgentPluginsController({ api, permissions = [] }, _optiona
83
88
  store.set({ patch: { pendingDisable: null } });
84
89
  return accepted;
85
90
  },
86
- toggleExpanded({ pluginId }, _optional = {}) { if (!canRead() || !rowFor(pluginId))
87
- return; const ids = store.getSnapshot().expandedIds; store.set({ patch: { expandedIds: ids.includes(pluginId) ? ids.filter(id => id !== pluginId) : [...ids, pluginId] } }); },
91
+ toggleExpanded({ pluginId }, _optional = {}) {
92
+ if (!canRead() || !rowFor(pluginId))
93
+ return;
94
+ const ids = store.getSnapshot().expandedIds;
95
+ store.set({ patch: { expandedIds: ids.includes(pluginId) ? ids.filter(id => id !== pluginId) : [...ids, pluginId] } });
96
+ },
88
97
  inspect({ pluginId }, _optional = {}) { if (canRead() && rowFor(pluginId))
89
98
  store.set({ patch: { inspectedId: pluginId } }); },
90
99
  closeInspector(_required, _optional = {}) { store.set({ patch: { inspectedId: null } }); },
@@ -32,14 +32,12 @@ export function buildPackageFileTree({ paths }, optional = {}) {
32
32
  for (const path of paths) {
33
33
  const segments = path.split("/").filter(Boolean);
34
34
  const fileName = segments.pop();
35
- if (!fileName)
36
- continue;
37
- const folder = folderAt({ root, segments });
38
- const normalized = [...segments, fileName].join("/");
39
- if (seen.has(normalized))
35
+ // A repeat of an already-listed path (after normalization) adds nothing; its folder exists.
36
+ const normalized = fileName ? [...segments, fileName].join("/") : null;
37
+ if (!fileName || normalized === null || seen.has(normalized))
40
38
  continue;
41
39
  seen.add(normalized);
42
- folder.files.push({ name: fileName, path });
40
+ folderAt({ root, segments }).files.push({ name: fileName, path });
43
41
  }
44
42
  return freeze(root, "", optional.locale === undefined ? collator : new Intl.Collator(optional.locale, { numeric: true, sensitivity: "base" }));
45
43
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Dialog, Notice } from '@jini-ai/ui-kit/react';
3
3
  import { useAgentPluginInspector } from '../hooks/AgentPluginInspector.hooks.js';
4
4
  import { AgentPluginFileTree } from './AgentPluginFileTree.js';
@@ -6,6 +6,9 @@ import { AgentPluginFileContent } from './AgentPluginFileContent.js';
6
6
  import { agentPluginsMessagesEn as m } from '../../messages.en.js';
7
7
  export function AgentPluginInspector(props, _optional = {}) {
8
8
  const vm = useAgentPluginInspector(props);
9
- return _jsx(Dialog, { open: true, title: vm.title, closeLabel: "Close package files", onClose: props.onClose, children: vm.denied ? _jsx(Notice, { tone: "danger", children: m.denied }) : _jsxs(_Fragment, { children: [vm.listNotice && _jsx("p", { role: "note", children: vm.listNotice }), _jsx(AgentPluginFileTree, { files: vm.files, selectedPath: vm.selectedPath, onOpen: vm.select }), vm.selected ? _jsx(AgentPluginFileContent, { file: vm.selected }, vm.selected.relativePath) : vm.status && _jsx("p", { role: vm.status.role, children: vm.status.text })] }) });
9
+ if (vm.denied)
10
+ return _jsx(Dialog, { open: true, title: vm.title, closeLabel: "Close package files", onClose: props.onClose, children: _jsx(Notice, { tone: "danger", children: m.denied }) });
11
+ const status = vm.status && _jsx("p", { role: vm.status.role, children: vm.status.text });
12
+ return _jsxs(Dialog, { open: true, title: vm.title, closeLabel: "Close package files", onClose: props.onClose, children: [vm.listNotice && _jsx("p", { role: "note", children: vm.listNotice }), _jsx(AgentPluginFileTree, { files: vm.files, selectedPath: vm.selectedPath, onOpen: vm.select }), vm.selected ? _jsx(AgentPluginFileContent, { file: vm.selected }, vm.selected.relativePath) : status] });
10
13
  }
11
14
  //# sourceMappingURL=AgentPluginInspector.js.map
@@ -27,6 +27,7 @@ function inspectorStatus({ state, hasFiles }, _optional = {}) {
27
27
  return hasFiles ? null : { text: m.filesEmpty, role: 'status' };
28
28
  }
29
29
  const fileGlyphs = { json: '{}', markdown: 'M↓', javascript: 'JS', typescript: 'TS', config: '⚙', html: '<>', css: '#', image: '▧', shell: '>', text: '¶', file: '▤' };
30
+ const folderGlyph = (expanded) => (expanded ? '▾' : '▸');
30
31
  export function useAgentPluginFileTree({ files, selectedPath, onOpen }, _optional = {}) {
31
32
  const tree = buildPackageFileTree({ paths: files.map(f => f.relativePath) }), [toggled, setToggled] = useState(new Map());
32
33
  // Set by keyboard navigation to the tree that received the key, so the effect always has a real element.
@@ -36,16 +37,21 @@ export function useAgentPluginFileTree({ files, selectedPath, onOpen }, _optiona
36
37
  const isExpanded = ({ path }) => toggled.get(path) ?? ancestors.has(path);
37
38
  const rows = visiblePackageFileRows({ nodes: tree, isExpanded });
38
39
  const visible = (path) => path !== null && rows.some(r => r.node.path === path);
39
- const activePath = visible(focused) ? focused : visible(selectedPath) ? selectedPath : rows[0]?.node.path ?? null;
40
+ const activePath = [focused, selectedPath].find(path => visible(path)) ?? rows[0]?.node.path ?? null;
40
41
  // Move DOM focus only after keyboard navigation, never when opening the inspector.
41
- useEffect(() => { const tree = focusWithin.current; if (!tree)
42
- return; focusWithin.current = null; Array.from(tree.querySelectorAll('[data-tree-path]')).find(el => el.dataset.treePath === activePath)?.focus(); });
42
+ useEffect(() => {
43
+ const tree = focusWithin.current;
44
+ if (!tree)
45
+ return;
46
+ focusWithin.current = null;
47
+ Array.from(tree.querySelectorAll('[data-tree-path]')).find(el => el.dataset.treePath === activePath)?.focus();
48
+ });
43
49
  const setExpanded = (path, expanded) => setToggled(current => new Map(current).set(path, expanded));
44
50
  return {
45
51
  rows: rows.map(row => {
46
52
  const { node } = row, folder = node.kind === 'folder', expanded = folder && isExpanded({ path: node.path });
47
53
  return { key: `${node.kind}:${node.path}`, name: node.name, path: node.path, depth: row.depth + 1, setSize: row.setSize, posInSet: row.posInSet, expanded: folder ? expanded : undefined, selected: folder ? undefined : node.path === selectedPath, tabIndex: node.path === activePath ? 0 : -1,
48
- icon: folder ? expanded ? '▾' : '▸' : fileGlyphs[packageFileIconKind({ fileName: node.name })], indent: { paddingInlineStart: `${row.depth * 1.25}rem`, ...(node.kind === 'file' && node.path === selectedPath ? { backgroundColor: 'Highlight', color: 'HighlightText' } : {}) },
54
+ icon: folder ? folderGlyph(expanded) : fileGlyphs[packageFileIconKind({ fileName: node.name })], indent: { paddingInlineStart: `${row.depth * 1.25}rem`, ...(node.kind === 'file' && node.path === selectedPath ? { backgroundColor: 'Highlight', color: 'HighlightText' } : {}) },
49
55
  onClick() { setFocused(node.path); if (folder)
50
56
  setExpanded(node.path, !expanded);
51
57
  else
@@ -38,11 +38,14 @@ function agentPluginRowView({ plugin, busy, expanded, controller, mode, canWrite
38
38
  const handle = `agent-plugin-${encodeURIComponent(plugin.pluginId)}`;
39
39
  const turnOff = () => controller?.requestDisable({ pluginId: plugin.pluginId, variant: mode === 'installed' ? 'disable' : 'remove' });
40
40
  // The switch and the button do the same thing: turning off asks first, enabling writes directly.
41
- const activate = () => { if (!canWrite)
42
- return; if (plugin.enabled)
43
- turnOff();
44
- else
45
- void controller?.setEnabled({ pluginId: plugin.pluginId, enabled: true }); };
41
+ const activate = () => {
42
+ if (!canWrite)
43
+ return;
44
+ if (plugin.enabled)
45
+ turnOff();
46
+ else
47
+ void controller?.setEnabled({ pluginId: plugin.pluginId, enabled: true });
48
+ };
46
49
  const verb = plugin.enabled ? 'Turn off' : 'Enable';
47
50
  return {
48
51
  id: plugin.pluginId, name, version: plugin.version, description: plugin.description,
@@ -23,7 +23,9 @@ export function selectAgentPluginFile({ files, path }, _optional = {}) {
23
23
  }
24
24
  const omissions = { binary: 'Binary file — not shown.', 'too-large': 'Too large to show here.', symlink: 'Symbolic link — not followed.', unreadable: 'This file could not be read.' };
25
25
  export function agentPluginFileNotice({ file }, _optional = {}) {
26
- return file.omitted === null && file.content !== null ? null : omissions[file.omitted ?? 'unreadable'] ?? omissions.unreadable;
26
+ if (file.omitted === null && file.content !== null)
27
+ return null;
28
+ return omissions[file.omitted ?? 'unreadable'] ?? omissions.unreadable;
27
29
  }
28
30
  /** Confirmation names the target and workspace effect; Turn off never implies file deletion. */
29
31
  export function agentPluginDisableCopy({ pluginId, variant }, _optional = {}) {
@@ -1,4 +1,5 @@
1
1
  import { AdminApiError } from '../../core/transport/errors.js';
2
+ import { querySuffix } from '../../core/transport/query-suffix.js';
2
3
  /** Preserve the host's decoded fields while adopting the shared core error owner. */
3
4
  async function request({ transport, ...required }, optional = {}) {
4
5
  try {
@@ -27,7 +28,7 @@ export function createHttpCommentsApi({ transport, basePath }, _optional = {}) {
27
28
  if (options.limit)
28
29
  params.set('limit', String(options.limit));
29
30
  const qs = params.toString();
30
- return request({ transport, path: `${base}/queue${qs ? `?${qs}` : ''}`, method: 'GET' });
31
+ return request({ transport, path: `${base}/queue${querySuffix(qs)}`, method: 'GET' });
31
32
  },
32
33
  async moderateComment({ commentId, action, expectedVersion }, options = {}) {
33
34
  await request({ transport, path: `${base}/${encodeURIComponent(commentId)}/${action}`, method: 'POST', body: { expectedVersion, note: options.note } });
@@ -7,7 +7,9 @@ const DEFAULT_SETTINGS = {
7
7
  };
8
8
  const MODERATION_STATUSES = { approve: 'approved', spam: 'spam', trash: 'trash', restore: 'approved' };
9
9
  function isNumberIn({ value, min, max }, { integer = false } = {}) {
10
- return Number.isFinite(value) && (!integer || Number.isInteger(value)) && value >= min && value <= max;
10
+ if (!Number.isFinite(value) || (integer && !Number.isInteger(value)))
11
+ return false;
12
+ return value >= min && value <= max;
11
13
  }
12
14
  /** Each settings field with its fixture-level shape check and the expectation a refusal names. */
13
15
  const SETTINGS_RULES = [
@@ -14,8 +14,9 @@ function hasConflictVersion({ settled, version }, _optional = {}) {
14
14
  if (!settled.threw)
15
15
  return false;
16
16
  const e = settled.error;
17
- return e instanceof Error && 'status' in e && e.status === 409 && 'body' in e
18
- && e.body?.currentVersion === version;
17
+ if (!(e instanceof Error && 'status' in e && e.status === 409))
18
+ return false;
19
+ return 'body' in e && e.body?.currentVersion === version;
19
20
  }
20
21
  async function checkPaging({ check }, _optional = {}) {
21
22
  const { api, assert } = check;
@@ -8,7 +8,7 @@ import { type CommentsTranslator } from '../messages.en.js';
8
8
  * @param optional Translator and an invalidation callback for every cached status.
9
9
  * @returns An instance-scoped store and actions; dispose makes pending settlements inert.
10
10
  */
11
- export declare function createCommentQueueController({ api, permissions }: {
11
+ export declare function createCommentQueueController({ api, permissions: initialPermissions }: {
12
12
  api: AdminCommentsPort;
13
13
  permissions?: readonly string[];
14
14
  }, { t, onInvalidate, loadPage }?: {
@@ -9,11 +9,12 @@ import { englishComments } from '../messages.en.js';
9
9
  * @param optional Translator and an invalidation callback for every cached status.
10
10
  * @returns An instance-scoped store and actions; dispose makes pending settlements inert.
11
11
  */
12
- export function createCommentQueueController({ api, permissions = [] }, { t = englishComments, onInvalidate, loadPage } = {}) {
12
+ export function createCommentQueueController({ api, permissions: initialPermissions = [] }, { t = englishComments, onInvalidate, loadPage } = {}) {
13
13
  const store = createControllerStore({ initial: {
14
14
  status: 'pending', items: null, nextCursor: null, error: null, loadingMore: false,
15
15
  rowState: {}, pendingPurge: null,
16
16
  } });
17
+ let permissions = initialPermissions;
17
18
  let firstPage = null;
18
19
  let morePages = [];
19
20
  let firstError = null;
@@ -95,7 +96,8 @@ export function createCommentQueueController({ api, permissions = [] }, { t = en
95
96
  if (store.signal.aborted || !nextCursor || loadingMore)
96
97
  return;
97
98
  loadingMore = true;
98
- const generation = ++pageGeneration;
99
+ pageGeneration += 1;
100
+ const generation = pageGeneration;
99
101
  moreError = null; // A retry clears the prior failure before its request settles.
100
102
  store.set({ patch: { loadingMore: true } });
101
103
  publishPages();
@@ -45,7 +45,8 @@ const MAX_JSON_NODES = 10_000;
45
45
  function describeNonJsonValue({ value, depth, budget }, _optional = {}) {
46
46
  if (depth > MAX_JSON_DEPTH)
47
47
  return `nests deeper than ${MAX_JSON_DEPTH} levels`;
48
- if (++budget.nodes > MAX_JSON_NODES)
48
+ budget.nodes += 1;
49
+ if (budget.nodes > MAX_JSON_NODES)
49
50
  return `holds more than ${MAX_JSON_NODES} values`;
50
51
  if (value === null || typeof value !== 'object')
51
52
  return describeNonJsonScalar(value);
@@ -95,6 +96,16 @@ function describeNonJson(value) {
95
96
  const reason = describeNonJsonValue({ value, depth: 0, budget: { nodes: 0 } });
96
97
  return reason === null ? null : `is not a JSON value: it ${reason}`;
97
98
  }
99
+ /** One describer per declared kind; the `Record` type makes a new kind without one fail typecheck. */
100
+ const FIELD_MISMATCH_BY_KIND = {
101
+ text: (value) => describeWrongType({ value, type: 'string', label: 'a string' }),
102
+ relation: (value) => describeWrongType({ value, type: 'string', label: 'a string' }),
103
+ datetime: describeNonDatetime,
104
+ integer: (value) => describeNonNumber({ value, accepts: Number.isInteger, reason: 'is not an integer' }),
105
+ real: (value) => describeNonNumber({ value, accepts: Number.isFinite, reason: 'is not a finite number' }),
106
+ boolean: (value) => describeWrongType({ value, type: 'boolean', label: 'a boolean' }),
107
+ json: describeNonJson,
108
+ };
98
109
  /**
99
110
  * Whether `value` is a legal runtime value for `field`.
100
111
  *
@@ -114,25 +125,7 @@ function describeNonJson(value) {
114
125
  * @overallScore 100
115
126
  */
116
127
  export function describeAdminEntityFieldMismatch(field, value) {
117
- switch (field.kind) {
118
- case 'text':
119
- case 'relation':
120
- return describeWrongType({ value, type: 'string', label: 'a string' });
121
- case 'datetime':
122
- return describeNonDatetime(value);
123
- case 'integer':
124
- return describeNonNumber({ value, accepts: Number.isInteger, reason: 'is not an integer' });
125
- case 'real':
126
- return describeNonNumber({
127
- value,
128
- accepts: Number.isFinite,
129
- reason: 'is not a finite number',
130
- });
131
- case 'boolean':
132
- return describeWrongType({ value, type: 'boolean', label: 'a boolean' });
133
- case 'json':
134
- return describeNonJson(value);
135
- }
128
+ return FIELD_MISMATCH_BY_KIND[field.kind](value);
136
129
  }
137
130
  /**
138
131
  * Every way `row` fails to match `descriptor`.
@@ -1,7 +1,8 @@
1
+ const bulletLine = (issue) => `- ${issue}`;
1
2
  export class AdminConfigError extends Error {
2
3
  issues;
3
4
  constructor({ issues }, _optional = {}) {
4
- super(`Invalid admin configuration:\n${issues.map((issue) => `- ${issue}`).join('\n')}`);
5
+ super(`Invalid admin configuration:\n${issues.map(bulletLine).join('\n')}`);
5
6
  this.name = 'AdminConfigError';
6
7
  this.issues = Object.freeze([...issues]);
7
8
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @file Copy an object without some of its keys — what `const { a: _a, ...rest } = source` does,
3
+ * without binding a throwaway name for every dropped key.
4
+ */
5
+ /** A shallow copy of `source`'s own enumerable properties, minus `keys`. @complexity O(own keys). */
6
+ export declare function omitKeys<T extends object, K extends keyof T>(source: T, keys: readonly K[]): Omit<T, K>;
7
+ //# sourceMappingURL=omit-keys.d.ts.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @file Copy an object without some of its keys — what `const { a: _a, ...rest } = source` does,
3
+ * without binding a throwaway name for every dropped key.
4
+ */
5
+ /** A shallow copy of `source`'s own enumerable properties, minus `keys`. @complexity O(own keys). */
6
+ export function omitKeys(source, keys) {
7
+ const copy = { ...source };
8
+ for (const key of keys)
9
+ delete copy[key];
10
+ return copy;
11
+ }
12
+ //# sourceMappingURL=omit-keys.js.map
@@ -63,6 +63,8 @@ export type FormFieldType = "text" | "email" | "textarea" | "checkbox" | (string
63
63
  /** Lifecycle of a form definition — open, see file header. There is no "deleted" state; see the
64
64
  * file header on why this port has no delete for definitions at all. */
65
65
  export type FormDefinitionStatus = "active" | "disabled" | (string & {});
66
+ /** How a form is authored: the field Builder or a hand-written HTML body. */
67
+ export type AdminFormMode = "builder" | "html";
66
68
  export interface AdminFormField {
67
69
  readonly id: string;
68
70
  readonly label: string;
@@ -101,7 +103,7 @@ export interface AdminFormDefinition {
101
103
  readonly createdAt: string;
102
104
  readonly updatedAt: string;
103
105
  /** HTML authoring is explicit; absent mode means the existing Builder contract. */
104
- readonly mode?: "builder" | "html" | undefined;
106
+ readonly mode?: AdminFormMode | undefined;
105
107
  readonly html?: string | undefined;
106
108
  }
107
109
  export interface AdminFormSubmission {
@@ -123,7 +125,7 @@ export interface AdminFormCreateInput {
123
125
  readonly name: string;
124
126
  readonly slug: string;
125
127
  readonly fields: readonly AdminFormField[];
126
- readonly mode?: "builder" | "html" | undefined;
128
+ readonly mode?: AdminFormMode | undefined;
127
129
  readonly html?: string | undefined;
128
130
  readonly notify?: AdminFormNotifyConfig;
129
131
  }
@@ -131,7 +133,7 @@ export interface AdminFormCreateInput {
131
133
  export interface AdminFormUpdatePatch {
132
134
  readonly name?: string;
133
135
  /** A mode/body patch uses the authoring route; an ordinary patch keeps the CRUD route. */
134
- readonly mode?: "builder" | "html" | undefined;
136
+ readonly mode?: AdminFormMode | undefined;
135
137
  readonly html?: string | undefined;
136
138
  /** Whole-array replace with an add/edit-only constraint — see file header. */
137
139
  readonly fields?: readonly AdminFormField[];
@@ -0,0 +1,3 @@
1
+ /** `"a=1"` -> `"?a=1"`; an empty query string adds nothing to the path. */
2
+ export declare function querySuffix(query: string): string;
3
+ //# sourceMappingURL=query-suffix.d.ts.map
@@ -0,0 +1,5 @@
1
+ /** `"a=1"` -> `"?a=1"`; an empty query string adds nothing to the path. */
2
+ export function querySuffix(query) {
3
+ return query ? `?${query}` : '';
4
+ }
5
+ //# sourceMappingURL=query-suffix.js.map
@@ -1,9 +1,10 @@
1
+ import { querySuffix } from '../../core/transport/query-suffix.js';
1
2
  /** Preserve the existing forms wire, including its data envelopes and explicit authoring route.
2
3
  * `mediaBasePath` (the host's media collection path) enables file-answer download links. */
3
4
  export function createHttpFormsApi({ transport, basePath }, { mediaBasePath } = {}) {
4
5
  const base = basePath.replace(/\/$/, '');
5
6
  const formPath = (id) => `${base}/${encodeURIComponent(id)}`;
6
- const submissionPath = (formId, submissionId) => `${formPath(formId)}/submissions${submissionId === undefined ? '' : `/${encodeURIComponent(submissionId)}`}`;
7
+ const submissionPath = (formId, submissionId) => `${formPath(formId)}/submissions${submissionId === undefined ? '' : '/' + encodeURIComponent(submissionId)}`;
7
8
  const mediaBase = mediaBasePath?.replace(/\/$/, '');
8
9
  return {
9
10
  ...(mediaBase === undefined ? {} : {
@@ -29,7 +30,7 @@ export function createHttpFormsApi({ transport, basePath }, { mediaBasePath } =
29
30
  if (options.limit)
30
31
  params.set('limit', String(options.limit));
31
32
  const qs = params.toString();
32
- const response = await transport.request({ path: `${submissionPath(formId)}${qs ? `?${qs}` : ''}`, method: 'GET' });
33
+ const response = await transport.request({ path: `${submissionPath(formId)}${querySuffix(qs)}`, method: 'GET' });
33
34
  return { items: response.data, nextCursor: response.nextCursor };
34
35
  },
35
36
  async getFormSubmission({ formId, submissionId }, _optional = {}) {
@@ -19,6 +19,7 @@ export function createMemoryFormsApi(_required = {}, { forms = [], submissions =
19
19
  const definitions = structuredClone([...forms]);
20
20
  const answers = structuredClone([...submissions]);
21
21
  let sequence = definitions.length;
22
+ const nextFakeId = () => { sequence += 1; return `fake-${sequence}`; };
22
23
  const missing = (id, { kind = 'form definition' } = {}) => {
23
24
  throw new AdminApiError({ message: `${kind} '${id}' was not found`, status: 404 });
24
25
  };
@@ -47,10 +48,10 @@ export function createMemoryFormsApi(_required = {}, { forms = [], submissions =
47
48
  async createFormDefinition(input, options = {}) {
48
49
  if (definitions.some(form => form.slug === input.slug))
49
50
  throw new AdminApiError({ message: 'form slug already exists', status: 409 });
50
- let id = nextId?.() ?? `fake-${++sequence}`;
51
+ let id = nextId?.() ?? nextFakeId();
51
52
  if (!nextId)
52
53
  while (definitions.some(form => form.id === id))
53
- id = `fake-${++sequence}`;
54
+ id = nextFakeId();
54
55
  if (definitions.some(form => form.id === id))
55
56
  throw new AdminApiError({ message: 'form id already exists', status: 409 });
56
57
  const created = { ...structuredClone(input), id, notify: structuredClone(options.notify ?? { enabled: false, recipients: [] }), status: 'active', createdAt: now(), updatedAt: now() };
@@ -5,7 +5,21 @@ export function initialFieldAttributesDialogState({ field }, _optional = {}) {
5
5
  return { className: field.className ?? "", rows: attrRowsFromField({ field }), settings: settingsFormFromField({ field }), error: null };
6
6
  }
7
7
  /** Add or remove one value from a list setting (accepted uploads, default choices). */
8
- const toggled = (values, value, on) => on ? (values.includes(value) ? values : [...values, value]) : values.filter((item) => item !== value);
8
+ const toggled = (values, value, on) => {
9
+ if (!on)
10
+ return values.filter((item) => item !== value);
11
+ return values.includes(value) ? values : [...values, value];
12
+ };
13
+ /** A single-select default holds at most the one chosen (non-empty) value. */
14
+ const singleChoice = (value, on) => (on && value ? [value] : []);
15
+ /** The first failure wins (settings before attributes); otherwise both patches merge. */
16
+ function mergeSubmitPatches(settings, attributes) {
17
+ if (!settings.ok)
18
+ return settings;
19
+ if (!attributes.ok)
20
+ return attributes;
21
+ return { ok: true, patch: { ...settings.patch, ...attributes.patch } };
22
+ }
9
23
  /** A headless section store. React owns subscriptions/effects, never this state. */
10
24
  export function createFieldAttributesDialogController({ field }, _optional = {}) {
11
25
  const store = createFormsDraftStore({ initial: initialFieldAttributesDialogState({ field }) });
@@ -18,7 +32,7 @@ export function createFieldAttributesDialogController({ field }, _optional = {})
18
32
  },
19
33
  /** `single` replaces the selection: a dropdown or radio group has one default. */
20
34
  toggleDefaultChoice({ value, on, single }, _options = {}) {
21
- editSettings(settings => ({ ...settings, defaultChoices: single ? (on && value ? [value] : []) : toggled(settings.defaultChoices, value, on) }));
35
+ editSettings(settings => ({ ...settings, defaultChoices: single ? singleChoice(value, on) : toggled(settings.defaultChoices, value, on) }));
22
36
  },
23
37
  toggleAccept({ value, on }, _options = {}) {
24
38
  editSettings(settings => ({ ...settings, accept: toggled(settings.accept, value, on) }));
@@ -43,7 +57,7 @@ export function createFieldAttributesDialogController({ field }, _optional = {})
43
57
  const snapshot = store.getSnapshot();
44
58
  const settings = buildFieldSettingsPatch({ form: snapshot.settings, field });
45
59
  const attributes = buildFieldAttributesPatch(snapshot);
46
- const result = !settings.ok ? settings : !attributes.ok ? attributes : { ok: true, patch: { ...settings.patch, ...attributes.patch } };
60
+ const result = mergeSubmitPatches(settings, attributes);
47
61
  store.update({ key: 'error', value: result.ok ? null : result.error });
48
62
  return result;
49
63
  },
@@ -4,7 +4,6 @@ export function initialFormFieldsEditorState(_required, _optional = {}) {
4
4
  }
5
5
  /** A headless section store. React owns subscriptions/effects, never this state. */
6
6
  export function createFormFieldsEditorController(_required, _optional = {}) {
7
- const store = createFormsDraftStore({ initial: initialFormFieldsEditorState({}) });
8
- return store;
7
+ return createFormsDraftStore({ initial: initialFormFieldsEditorState({}) });
9
8
  }
10
9
  //# sourceMappingURL=form-fields-editor.controller.js.map
@@ -4,7 +4,6 @@ export function initialFormSubmissionDetailState(_required, _optional = {}) {
4
4
  }
5
5
  /** A headless section store. React owns subscriptions/effects, never this state. */
6
6
  export function createFormSubmissionDetailController(_required, _optional = {}) {
7
- const store = createFormsDraftStore({ initial: initialFormSubmissionDetailState({}) });
8
- return store;
7
+ return createFormsDraftStore({ initial: initialFormSubmissionDetailState({}) });
9
8
  }
10
9
  //# sourceMappingURL=form-submission-detail.controller.js.map
@@ -4,7 +4,6 @@ export function initialFormSubmissionsState(_required, _optional = {}) {
4
4
  }
5
5
  /** A headless section store. React owns subscriptions/effects, never this state. */
6
6
  export function createFormSubmissionsController(_required, _optional = {}) {
7
- const store = createFormsDraftStore({ initial: initialFormSubmissionsState({}) });
8
- return store;
7
+ return createFormsDraftStore({ initial: initialFormSubmissionsState({}) });
9
8
  }
10
9
  //# sourceMappingURL=form-submissions.controller.js.map
@@ -4,7 +4,6 @@ export function initialFormsListState(_required, _optional = {}) {
4
4
  }
5
5
  /** A headless section store. React owns subscriptions/effects, never this state. */
6
6
  export function createFormsListController(_required, _optional = {}) {
7
- const store = createFormsDraftStore({ initial: initialFormsListState({}) });
8
- return store;
7
+ return createFormsDraftStore({ initial: initialFormsListState({}) });
9
8
  }
10
9
  //# sourceMappingURL=forms-list.controller.js.map
@@ -1,3 +1,4 @@
1
+ import { trimChars } from "@jini-ai/core/text";
1
2
  import { FIELD_TYPE_REGISTRY, isBrowserSafeFieldPattern, MAX_FORM_FILE_BYTES, SAFE_FORM_FILE_TYPES } from "@jini-ai/cms/forms";
2
3
  const kindOf = (type) => Object.hasOwn(FIELD_TYPE_REGISTRY, type) ? FIELD_TYPE_REGISTRY[type] : undefined;
3
4
  /** Plain-language type names (translation keys). Exhaustive over the registry by type. */
@@ -86,8 +87,11 @@ export function fieldTypeGroups({ current }, _optional = {}) {
86
87
  }
87
88
  /** Lowercase letters, digits and `_`, starting with a letter: the CMS `FIELD_ID_PATTERN`. */
88
89
  export function fieldIdFromLabel({ label }, _optional = {}) {
89
- const slug = label.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 60);
90
- return slug === "" ? "" : /^[a-z]/.test(slug) ? slug : `field_${slug}`;
90
+ const underscored = label.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_");
91
+ const slug = trimChars({ text: underscored, chars: "_" }).slice(0, 60);
92
+ if (slug === "")
93
+ return "";
94
+ return /^[a-z]/.test(slug) ? slug : `field_${slug}`;
91
95
  }
92
96
  /**
93
97
  * A label edit on an unsaved field also renames its id while the id still follows the label (empty
@@ -130,6 +134,12 @@ function retainedKeys({ field, before, after }) {
130
134
  ];
131
135
  }
132
136
  const keepsDefault = ({ before, after }) => after.defaultKind !== null && after.defaultKind === before.defaultKind && after.bounds === before.bounds;
137
+ /** Arrays and objects come back as fresh one-level copies; anything else as is. */
138
+ function shallowCopy(value) {
139
+ if (Array.isArray(value))
140
+ return [...value];
141
+ return typeof value === "object" && value !== null ? { ...value } : value;
142
+ }
133
143
  /** The listed keys the field has set, in order; attribute maps and accept lists are copied, not shared. */
134
144
  function setKeys({ field, keys }) {
135
145
  const kept = {};
@@ -137,7 +147,7 @@ function setKeys({ field, keys }) {
137
147
  const value = field[key];
138
148
  if (value === undefined)
139
149
  continue;
140
- Object.assign(kept, { [key]: Array.isArray(value) ? [...value] : typeof value === "object" && value !== null ? { ...value } : value });
150
+ Object.assign(kept, { [key]: shallowCopy(value) });
141
151
  }
142
152
  return kept;
143
153
  }
@@ -215,7 +225,8 @@ function optionalNumber(value, error, { integer = false } = {}) {
215
225
  if (isBlank(value))
216
226
  return { ok: true, value: undefined };
217
227
  const number = Number(value);
218
- return Number.isFinite(number) && (!integer || (Number.isInteger(number) && number >= 0)) ? { ok: true, value: number } : { ok: false, error };
228
+ const wholeEnough = !integer || (Number.isInteger(number) && number >= 0);
229
+ return Number.isFinite(number) && wholeEnough ? { ok: true, value: number } : { ok: false, error };
219
230
  }
220
231
  /** Text length, pattern and placeholder settings. */
221
232
  function textPatch({ form, settings }) {