@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.
- package/dist/agent-plugins/adapters/memory.js +9 -3
- package/dist/agent-plugins/conformance/agent-plugins-api.conformance.js +1 -1
- package/dist/agent-plugins/controllers/agent-plugins.controller.js +14 -5
- package/dist/agent-plugins/package-file-tree.js +4 -6
- package/dist/agent-plugins/react/components/AgentPluginInspector.js +5 -2
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +10 -4
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +8 -5
- package/dist/agent-plugins/rules.js +3 -1
- package/dist/comments/adapters/http.js +2 -1
- package/dist/comments/adapters/memory.js +3 -1
- package/dist/comments/conformance/comments-api.conformance.js +3 -2
- package/dist/comments/controllers/comment-queue.controller.d.ts +1 -1
- package/dist/comments/controllers/comment-queue.controller.js +4 -2
- package/dist/core/entities/rules.js +13 -20
- package/dist/core/module/create-admin.js +2 -1
- package/dist/core/omit-keys.d.ts +7 -0
- package/dist/core/omit-keys.js +12 -0
- package/dist/core/ports/forms.d.ts +5 -3
- package/dist/core/transport/query-suffix.d.ts +3 -0
- package/dist/core/transport/query-suffix.js +5 -0
- package/dist/forms/adapters/http.js +3 -2
- package/dist/forms/adapters/memory.js +3 -2
- package/dist/forms/controllers/field-attributes-dialog.controller.js +17 -3
- package/dist/forms/controllers/form-fields-editor.controller.js +1 -2
- package/dist/forms/controllers/form-submission-detail.controller.js +1 -2
- package/dist/forms/controllers/form-submissions.controller.js +1 -2
- package/dist/forms/controllers/forms-list.controller.js +1 -2
- package/dist/forms/field-settings.js +15 -4
- package/dist/forms/html-rules.js +10 -13
- package/dist/forms/react/hooks/forms-view.hooks.d.ts +2 -2
- package/dist/forms/react/hooks/forms-view.hooks.js +9 -1
- package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +1 -3
- package/dist/forms/react/hooks/use-form-editor.hooks.js +8 -9
- package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -4
- package/dist/forms/react/hooks/use-form-submissions.hooks.js +1 -5
- package/dist/forms/react/hooks/use-forms-list.hooks.js +1 -2
- package/dist/forms/rules.js +7 -2
- package/dist/media/adapters/memory.js +5 -3
- package/dist/media/controllers/library.controller.js +4 -2
- package/dist/media/controllers/providers.controller.js +2 -1
- package/dist/media/html-attributes.d.ts +1 -8
- package/dist/media/html-attributes.js +6 -161
- package/dist/media/react/components/MediaCard.js +11 -1
- package/dist/media/react/hooks/EditMediaPanel.hooks.js +18 -7
- package/dist/media/react/hooks/LibraryTab.hooks.js +3 -2
- package/dist/media/react/hooks/ProvidersTab.hooks.js +19 -9
- package/dist/media/rules.js +6 -3
- package/dist/playground/adapters/memory.js +26 -7
- package/dist/playground/conformance/playground-api.conformance.js +6 -3
- package/dist/playground/react/hooks/PlaygroundPorts.hooks.js +6 -2
- package/dist/react/components/ConfirmButton/ConfirmButton.hooks.js +1 -1
- package/dist/react/components/ConfirmButton/ConfirmButton.js +2 -5
- package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +2 -2
- package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +3 -1
- package/dist/react/components/RowMenu/RowMenu.hooks.js +4 -8
- package/dist/react/components/Sidebar.js +1 -1
- package/dist/react/entities/EntityField.js +1 -0
- package/dist/react/entities/contribution.js +2 -1
- package/dist/react/entities/types.js +1 -1
- package/dist/react/overlays.js +2 -1
- package/dist/react/shell/AdminShell.hooks.js +1 -1
- package/dist/react/shell/use-admin-shell-session.js +2 -1
- package/dist/react/shell/use-admin-theme.js +6 -6
- package/dist/redirects/adapters/memory.js +16 -13
- package/dist/security/adapters/http.js +10 -11
- package/dist/security/adapters/memory.js +50 -15
- package/dist/security/conformance/security-api.conformance.js +6 -3
- package/dist/security/controllers/access-tokens.controller.js +7 -3
- package/dist/security/controllers/other-credentials.controller.js +4 -2
- package/dist/security/controllers/root-key.controller.js +12 -5
- package/dist/security/react/hooks/AccessTokensTab.hooks.js +8 -3
- package/dist/security/react/hooks/SecurityPorts.hooks.js +6 -2
- package/dist/security/rules.js +19 -5
- package/dist/seo/adapters/memory.js +5 -2
- package/dist/seo/controllers/seo-entry.controller.js +2 -1
- package/dist/seo/controllers/sitemap.controller.js +2 -1
- package/dist/seo/react/hooks/MediaPickerBridge.hooks.js +1 -1
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -21
- package/dist/seo/react/hooks/SeoPage.hooks.js +0 -8
- package/dist/seo/react/hooks/use-seo.hooks.js +1 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -1
- package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +9 -1
- package/dist/seo/react/pages/SeoPage.js +17 -3
- package/dist/source-control/adapters/memory.js +30 -14
- package/dist/source-control/controllers/source-control.controller.js +5 -2
- package/dist/source-control/react/components/SourceControlProviderRow.js +6 -1
- package/dist/source-control/react/hooks/SourceControlPorts.hooks.js +6 -2
- package/dist/source-control/rules.js +9 -3
- package/dist/widgets/adapters/http.js +2 -1
- package/dist/widgets/adapters/memory.js +4 -2
- package/dist/widgets/conformance/widgets-api.conformance.js +5 -2
- package/dist/widgets/controllers/instance-editor.controller.js +3 -2
- package/dist/widgets/controllers/library.controller.js +2 -1
- package/dist/widgets/controllers/region-editor.controller.js +2 -1
- package/dist/widgets/controllers/regions.controller.js +2 -1
- package/dist/widgets/react/components/WidgetInstanceNotices.d.ts +1 -1
- package/dist/widgets/react/components/WidgetInstanceNotices.js +2 -0
- package/dist/widgets/react/hooks/use-widget-regions.hooks.js +5 -2
- package/dist/widgets/react/hooks/use-widgets-library.hooks.js +5 -2
- package/dist/widgets/react/pages/WidgetInstanceEditor.js +1 -1
- package/dist/widgets/react/pages/WidgetRegionEditor.js +1 -1
- package/dist/widgets/react/pages/WidgetsLibrary.js +1 -1
- package/dist/widgets/rules.js +5 -1
- 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 = {}) {
|
|
21
|
-
|
|
22
|
-
|
|
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(
|
|
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
|
-
|
|
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 = {}) {
|
|
71
|
-
|
|
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 = {}) {
|
|
87
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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(() => {
|
|
42
|
-
|
|
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
|
|
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 = () => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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[];
|
|
@@ -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 ? '' :
|
|
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
|
|
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?.() ??
|
|
51
|
+
let id = nextId?.() ?? nextFakeId();
|
|
51
52
|
if (!nextId)
|
|
52
53
|
while (definitions.some(form => form.id === id))
|
|
53
|
-
id =
|
|
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) =>
|
|
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 ? (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
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]:
|
|
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
|
-
|
|
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 }) {
|