@jini-ai/admin 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/dist/agent-plugins/react/components/AgentPluginFileTree.js +1 -1
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +0 -1
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +6 -5
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +3 -2
- package/dist/media/react/hooks/LibraryTab.hooks.d.ts +0 -2
- package/dist/media/react/hooks/LibraryTab.hooks.js +7 -16
- package/dist/media/react/hooks/ProvidersTab.hooks.js +5 -1
- package/dist/media/react/hooks/UploadButton.hooks.d.ts +9 -2
- package/dist/media/react/hooks/UploadButton.hooks.js +12 -10
- package/dist/react/components/RowMenu/RowMenu.hooks.d.ts +23 -16
- package/dist/react/components/RowMenu/RowMenu.hooks.js +74 -54
- package/dist/react/entities/EntityField.d.ts +1 -1
- package/dist/react/entities/EntityField.js +4 -2
- package/dist/react/entities/types.d.ts +9 -0
- package/dist/react/entities/types.js +9 -0
- package/dist/security/react/hooks/AccessTokensTab.hooks.js +2 -2
- package/dist/security/react/hooks/RootKeyTab.hooks.js +2 -3
- package/dist/security/react/hooks/SecurityPorts.hooks.d.ts +4 -0
- package/dist/security/react/hooks/SecurityPorts.hooks.js +5 -0
- package/dist/source-control/react/hooks/ProvidersTab.hooks.js +3 -3
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2 — 2026-10-05
|
|
4
|
+
|
|
5
|
+
- Catalog provider settings: on a host without `saveChanges`, a provider the last read did not return (a catalog-only provider's first key or settings) is now written instead of silently dropped while the tab reported Saved.
|
|
6
|
+
- `RowMenu`: positioning and click-outside are tracked from the menu's callback ref (`trackOpenMenu`); `RowMenuState` is an exported interface and its `menuRef` accepts both callback and object refs. Behavior unchanged.
|
|
7
|
+
- Entity fields: both `Select`s share one `selectTranslate` adapter, so the boolean field's `Select` copy also goes through the host dictionary.
|
|
8
|
+
|
|
3
9
|
## 0.4.1 — 2026-10-04
|
|
4
10
|
|
|
5
11
|
- Media cards show the original file size and upload date (`formatUploadDate` exported from the media barrel); a null size is hidden.
|
|
@@ -15,6 +21,9 @@
|
|
|
15
21
|
|
|
16
22
|
## Unreleased
|
|
17
23
|
|
|
24
|
+
- Media Providers tab on hosts without `saveChanges`: saving the first key or
|
|
25
|
+
settings of a catalog provider the last read did not return now writes them
|
|
26
|
+
(previously nothing was written, yet the tab showed "Saved").
|
|
18
27
|
- Fix strict compilation of entity tables and integration/session test fixtures
|
|
19
28
|
with explicit row, deferred-promise and tuple types. Runtime behavior and test
|
|
20
29
|
assertions are unchanged.
|
|
@@ -2,6 +2,6 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useAgentPluginFileTree } from '../hooks/AgentPluginInspector.hooks.js';
|
|
3
3
|
export function AgentPluginFileTree({ files, selectedPath, onOpen }, _optional = {}) {
|
|
4
4
|
const vm = useAgentPluginFileTree({ files, selectedPath, onOpen });
|
|
5
|
-
return _jsx("div", {
|
|
5
|
+
return _jsx("div", { role: "tree", "aria-label": "Package files", onKeyDown: vm.onKeyDown, children: vm.rows.map(row => _jsxs("div", { role: "treeitem", "aria-label": row.name, "aria-level": row.depth, "aria-setsize": row.setSize, "aria-posinset": row.posInSet, "aria-expanded": row.expanded, "aria-selected": row.selected, tabIndex: row.tabIndex, title: row.path, "data-tree-path": row.path, style: row.indent, onClick: row.onClick, onFocus: row.onFocus, children: [_jsxs("span", { "aria-hidden": true, children: [row.icon, " "] }), row.name] }, row.key)) });
|
|
6
6
|
}
|
|
7
7
|
//# sourceMappingURL=AgentPluginFileTree.js.map
|
|
@@ -34,7 +34,6 @@ export declare function useAgentPluginFileTree({ files, selectedPath, onOpen }:
|
|
|
34
34
|
path: string;
|
|
35
35
|
}, optional?: Record<string, never>) => void;
|
|
36
36
|
}, _optional?: {}): {
|
|
37
|
-
treeRef: import("react").RefObject<HTMLDivElement | null>;
|
|
38
37
|
rows: {
|
|
39
38
|
key: string;
|
|
40
39
|
name: string;
|
|
@@ -20,7 +20,8 @@ export function useAgentPluginInspector({ pluginId, permissions, name }, _option
|
|
|
20
20
|
const fileGlyphs = { json: '{}', markdown: 'M↓', javascript: 'JS', typescript: 'TS', config: '⚙', html: '<>', css: '#', image: '▧', shell: '>', text: '¶', file: '▤' };
|
|
21
21
|
export function useAgentPluginFileTree({ files, selectedPath, onOpen }, _optional = {}) {
|
|
22
22
|
const tree = buildPackageFileTree({ paths: files.map(f => f.relativePath) }), [toggled, setToggled] = useState(new Map());
|
|
23
|
-
|
|
23
|
+
// Set by keyboard navigation to the tree that received the key, so the effect always has a real element.
|
|
24
|
+
const [focused, setFocused] = useState(null), focusWithin = useRef(null);
|
|
24
25
|
// Expansion is derived from selected ancestors until the viewer explicitly toggles a folder.
|
|
25
26
|
const ancestors = new Set(selectedPath ? ancestorFolderPaths({ filePath: selectedPath }) : []);
|
|
26
27
|
const isExpanded = ({ path }) => toggled.get(path) ?? ancestors.has(path);
|
|
@@ -28,10 +29,10 @@ export function useAgentPluginFileTree({ files, selectedPath, onOpen }, _optiona
|
|
|
28
29
|
const visible = (path) => path !== null && rows.some(r => r.node.path === path);
|
|
29
30
|
const activePath = visible(focused) ? focused : visible(selectedPath) ? selectedPath : rows[0]?.node.path ?? null;
|
|
30
31
|
// Move DOM focus only after keyboard navigation, never when opening the inspector.
|
|
31
|
-
useEffect(() => { if (!
|
|
32
|
-
return;
|
|
32
|
+
useEffect(() => { const tree = focusWithin.current; if (!tree)
|
|
33
|
+
return; focusWithin.current = null; Array.from(tree.querySelectorAll('[data-tree-path]')).find(el => el.dataset.treePath === activePath)?.focus(); });
|
|
33
34
|
const setExpanded = (path, expanded) => setToggled(current => new Map(current).set(path, expanded));
|
|
34
|
-
return {
|
|
35
|
+
return {
|
|
35
36
|
rows: rows.map(row => {
|
|
36
37
|
const { node } = row, folder = node.kind === 'folder', expanded = folder && isExpanded({ path: node.path });
|
|
37
38
|
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,
|
|
@@ -55,7 +56,7 @@ export function useAgentPluginFileTree({ files, selectedPath, onOpen }, _optiona
|
|
|
55
56
|
onOpen({ path: action.open });
|
|
56
57
|
if (action.focus) {
|
|
57
58
|
setFocused(action.focus);
|
|
58
|
-
|
|
59
|
+
focusWithin.current = event.currentTarget;
|
|
59
60
|
}
|
|
60
61
|
},
|
|
61
62
|
};
|
|
@@ -7,9 +7,10 @@ export function useAgentPluginPanel({ props, mode }, _optional = {}) {
|
|
|
7
7
|
const canRead = permissions.includes(AGENT_PLUGINS_READ) && !!props.permissions?.includes(AGENT_PLUGINS_READ);
|
|
8
8
|
const canWrite = canRead && permissions.includes(AGENT_PLUGINS_WRITE) && !!props.permissions?.includes(AGENT_PLUGINS_WRITE);
|
|
9
9
|
// Scope is intentionally unfiltered on BOTH tabs, including switched-off bundled packages.
|
|
10
|
+
const busyIds = state?.busyIds ?? [], expandedIds = state?.expandedIds ?? [];
|
|
10
11
|
const rows = (canRead ? state?.plugins ?? [] : []).map(plugin => {
|
|
11
|
-
const name = humanizeAgentPluginId({ pluginId: plugin.pluginId }), busy =
|
|
12
|
-
const expanded =
|
|
12
|
+
const name = humanizeAgentPluginId({ pluginId: plugin.pluginId }), busy = busyIds.includes(plugin.pluginId);
|
|
13
|
+
const expanded = expandedIds.includes(plugin.pluginId);
|
|
13
14
|
// Derived from the id alone, so handles survive sorting and switching tabs.
|
|
14
15
|
const handle = `agent-plugin-${encodeURIComponent(plugin.pluginId)}`;
|
|
15
16
|
const turnOff = () => controller?.requestDisable({ pluginId: plugin.pluginId, variant: mode === 'installed' ? 'disable' : 'remove' });
|
|
@@ -67,8 +67,6 @@ export declare function useLibraryTab({ params, permissions }: TabViewProps, _op
|
|
|
67
67
|
onOrder: ({ value }: {
|
|
68
68
|
value: string;
|
|
69
69
|
}) => void;
|
|
70
|
-
onOrderCreated: () => void;
|
|
71
|
-
onOrderAlphabetical: () => void;
|
|
72
70
|
search: string;
|
|
73
71
|
loading: boolean;
|
|
74
72
|
busy: boolean;
|
|
@@ -51,7 +51,7 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
|
51
51
|
// Lightbox — an index into the list, not the item itself, so arrow-key/navigation
|
|
52
52
|
// buttons move the number without re-deriving "what's next" from an item reference.
|
|
53
53
|
const [lightboxIndex, setLightboxIndex] = useState(null);
|
|
54
|
-
const items = snapshot?.items ?? [];
|
|
54
|
+
const items = snapshot?.items ?? [], pendingIds = snapshot?.pendingIds ?? [];
|
|
55
55
|
const canUpload = permissions.includes('media.upload');
|
|
56
56
|
const canEdit = permissions.includes('media.update');
|
|
57
57
|
const canTrash = permissions.includes('media.trash');
|
|
@@ -59,7 +59,7 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
|
59
59
|
const canPurge = permissions.includes('media.delete.force');
|
|
60
60
|
const actions = items.map((item, index) => ({
|
|
61
61
|
item,
|
|
62
|
-
busy:
|
|
62
|
+
busy: pendingIds.includes(item.id),
|
|
63
63
|
...(canEdit ? { onEdit: () => setEditing(item) } : {}),
|
|
64
64
|
onPreview: () => setLightboxIndex(index),
|
|
65
65
|
...(canTrash
|
|
@@ -123,16 +123,6 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
|
123
123
|
onOrder: ({ value }) => {
|
|
124
124
|
void controller?.setQuery({ query: { ...controller.getSnapshot().query, orderBy: value === 'alphabetical' ? 'alphabetical' : 'created' } });
|
|
125
125
|
},
|
|
126
|
-
onOrderCreated: () => {
|
|
127
|
-
void controller?.setQuery({
|
|
128
|
-
query: { ...controller.getSnapshot().query, orderBy: 'created' },
|
|
129
|
-
});
|
|
130
|
-
},
|
|
131
|
-
onOrderAlphabetical: () => {
|
|
132
|
-
void controller?.setQuery({
|
|
133
|
-
query: { ...controller.getSnapshot().query, orderBy: 'alphabetical' },
|
|
134
|
-
});
|
|
135
|
-
},
|
|
136
126
|
search: snapshot?.query.search ?? '',
|
|
137
127
|
loading: !snapshot || (snapshot.items === null && snapshot.loading && !snapshot.error),
|
|
138
128
|
busy: snapshot?.busy ?? false,
|
|
@@ -147,13 +137,14 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
|
147
137
|
},
|
|
148
138
|
cancelPurge: () => controller?.cancelPurge(),
|
|
149
139
|
confirmPurge: () => {
|
|
150
|
-
|
|
140
|
+
const item = controller?.getSnapshot().pendingPurge;
|
|
141
|
+
// The controller refuses to purge without a pending item, so there is nothing to announce.
|
|
142
|
+
if (!canPurge || !controller || !item)
|
|
151
143
|
return;
|
|
152
|
-
const
|
|
153
|
-
const index = items.findIndex(row => row.id === item?.id);
|
|
144
|
+
const index = items.findIndex(row => row.id === item.id);
|
|
154
145
|
return controller.confirmPurge({ confirmed: true }).then(saved => {
|
|
155
146
|
if (saved) {
|
|
156
|
-
setSuccess(
|
|
147
|
+
setSuccess(`Deleted permanently ${item.title}`);
|
|
157
148
|
setFocusAfterPurge(Math.max(0, index));
|
|
158
149
|
}
|
|
159
150
|
});
|
|
@@ -48,7 +48,11 @@ export function useProvidersTab(_required = {}, _optional = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
// Older hosts retain the one-button editor; writes remain sequential and failures
|
|
50
50
|
// retain local edits. Atomic whole-set hosts should implement saveChanges instead.
|
|
51
|
-
|
|
51
|
+
// Submitted ids the last read never returned (catalog-only providers getting their
|
|
52
|
+
// first key or settings) start from an empty stored state, or their writes are lost.
|
|
53
|
+
const known = new Set(lastRead.map(item => item.id));
|
|
54
|
+
const unread = Object.keys(providers).filter(id => !known.has(id)).map(id => ({ id, configured: false }));
|
|
55
|
+
for (const item of [...lastRead, ...unread]) {
|
|
52
56
|
const entry = providers[item.id];
|
|
53
57
|
if (!entry) {
|
|
54
58
|
if (item.configured || item.baseUrl || item.model)
|
|
@@ -14,10 +14,17 @@ export interface UploadButtonProps {
|
|
|
14
14
|
/** Reads a browser File at the React boundary; headless controllers only receive bytes. */
|
|
15
15
|
export declare function readMediaFile({ file }: {
|
|
16
16
|
file: File;
|
|
17
|
-
}, { signal }?: {
|
|
17
|
+
}, { signal, createReader }?: {
|
|
18
18
|
signal?: AbortSignal;
|
|
19
|
+
createReader?: () => FileReader;
|
|
19
20
|
}): Promise<UploadInput>;
|
|
20
|
-
export declare function useUploadButton(props: UploadButtonProps,
|
|
21
|
+
export declare function useUploadButton(props: UploadButtonProps, { readFile }?: {
|
|
22
|
+
readFile?: (required: {
|
|
23
|
+
file: File;
|
|
24
|
+
}, optional?: {
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
}) => Promise<UploadInput>;
|
|
27
|
+
}): {
|
|
21
28
|
alt: string;
|
|
22
29
|
withAlt: boolean;
|
|
23
30
|
multiple: boolean;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
/** Reads a browser File at the React boundary; headless controllers only receive bytes. */
|
|
3
|
-
export function readMediaFile({ file }, { signal } = {}) {
|
|
3
|
+
export function readMediaFile({ file }, { signal, createReader = () => new FileReader() } = {}) {
|
|
4
4
|
return new Promise((resolve, reject) => {
|
|
5
5
|
signal?.throwIfAborted();
|
|
6
|
-
const reader =
|
|
6
|
+
const reader = createReader();
|
|
7
7
|
const abort = () => {
|
|
8
8
|
reader.abort();
|
|
9
9
|
reject(new Error('File read aborted'));
|
|
@@ -26,13 +26,15 @@ export function readMediaFile({ file }, { signal } = {}) {
|
|
|
26
26
|
reader.readAsDataURL(file);
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
-
export function useUploadButton(props,
|
|
29
|
+
export function useUploadButton(props, { readFile = readMediaFile } = {}) {
|
|
30
30
|
// Advisory picker formats mirror the host's standard ceiling, including video. The
|
|
31
31
|
// injected server still validates/sniffs bytes; a file picker is never a security boundary.
|
|
32
32
|
const inputRef = useRef(null);
|
|
33
33
|
const [selectedFiles, setSelectedFiles] = useState([]);
|
|
34
34
|
const [alt, setAlt] = useState(''), [busy, setBusy] = useState(false), [error, setError] = useState(null), [success, setSuccess] = useState(null);
|
|
35
|
-
|
|
35
|
+
// The mount effect swaps in a fresh controller (StrictMode replays it), so handlers,
|
|
36
|
+
// which only run after mount, always read a live signal.
|
|
37
|
+
const inFlight = useRef(false), lifetime = useRef(new AbortController());
|
|
36
38
|
useEffect(() => {
|
|
37
39
|
const abort = new AbortController();
|
|
38
40
|
lifetime.current = abort;
|
|
@@ -48,7 +50,7 @@ export function useUploadButton(props, _optional = {}) {
|
|
|
48
50
|
async function uploadFiles(files) {
|
|
49
51
|
if (!files.length || inFlight.current || props.disabled)
|
|
50
52
|
return;
|
|
51
|
-
const signal = lifetime.current
|
|
53
|
+
const signal = lifetime.current.signal;
|
|
52
54
|
inFlight.current = true;
|
|
53
55
|
setBusy(true);
|
|
54
56
|
setError(null);
|
|
@@ -57,12 +59,12 @@ export function useUploadButton(props, _optional = {}) {
|
|
|
57
59
|
let remaining = [...files];
|
|
58
60
|
try {
|
|
59
61
|
for (const file of files) {
|
|
60
|
-
const input = await
|
|
61
|
-
if (signal
|
|
62
|
+
const input = await readFile({ file }, { signal });
|
|
63
|
+
if (signal.aborted)
|
|
62
64
|
return;
|
|
63
65
|
if (!await props.upload({ input, ...(alt.trim() ? { alt: alt.trim() } : {}) }))
|
|
64
66
|
throw new Error(`Upload failed for ${file.name}`);
|
|
65
|
-
if (signal
|
|
67
|
+
if (signal.aborted)
|
|
66
68
|
return;
|
|
67
69
|
uploaded.push(file.name);
|
|
68
70
|
remaining = remaining.slice(1);
|
|
@@ -72,12 +74,12 @@ export function useUploadButton(props, _optional = {}) {
|
|
|
72
74
|
inputRef.current.value = '';
|
|
73
75
|
}
|
|
74
76
|
catch (error) {
|
|
75
|
-
if (!signal
|
|
77
|
+
if (!signal.aborted)
|
|
76
78
|
setError(error instanceof Error ? error.message : 'Upload failed');
|
|
77
79
|
}
|
|
78
80
|
finally {
|
|
79
81
|
inFlight.current = false;
|
|
80
|
-
if (!signal
|
|
82
|
+
if (!signal.aborted) {
|
|
81
83
|
// Keep only failed/unattempted files: retry must not duplicate successes in a batch.
|
|
82
84
|
setSelectedFiles(remaining);
|
|
83
85
|
setBusy(false);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type KeyboardEvent } from 'react';
|
|
1
|
+
import { type KeyboardEvent, type RefObject } from 'react';
|
|
2
2
|
/**
|
|
3
3
|
* @file `RowMenu`'s open/close state, viewport-aware positioning, and keyboard/focus behavior,
|
|
4
4
|
* split out of the component so it can be swapped for a fake via the `useRowMenu` prop on
|
|
@@ -9,6 +9,25 @@ interface Position {
|
|
|
9
9
|
left: number;
|
|
10
10
|
placement: 'below' | 'above';
|
|
11
11
|
}
|
|
12
|
+
type RowMenuWindow = Pick<Window, 'innerHeight' | 'innerWidth' | 'addEventListener' | 'removeEventListener'>;
|
|
13
|
+
type RowMenuDocument = Pick<Document, 'addEventListener' | 'removeEventListener'>;
|
|
14
|
+
/** What `useRowMenu` hands `RowMenu` — and the shape an injected `useRowMenu` fake must return. */
|
|
15
|
+
export interface RowMenuState {
|
|
16
|
+
readonly open: boolean;
|
|
17
|
+
readonly position: Position | null;
|
|
18
|
+
readonly triggerRef: RefObject<HTMLButtonElement | null>;
|
|
19
|
+
/** A callback ref from the real hook (see {@link trackOpenMenu}; `null` while closed), or a host
|
|
20
|
+
* hook's ref object. Spelled out rather than React's `Ref`, whose callback form carries a brand
|
|
21
|
+
* that fails to match across two installed `@types/react` copies (Jini's vs a host app's). */
|
|
22
|
+
readonly menuRef: RefObject<HTMLDivElement | null> | ((menu: HTMLDivElement | null) => void) | null;
|
|
23
|
+
readonly itemRefs: RefObject<Array<HTMLButtonElement | null>>;
|
|
24
|
+
readonly onTriggerClick: () => void;
|
|
25
|
+
readonly onTriggerKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;
|
|
26
|
+
readonly onMenuKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
|
|
27
|
+
readonly selectItem: (item: {
|
|
28
|
+
readonly onSelect: () => void;
|
|
29
|
+
}) => void;
|
|
30
|
+
}
|
|
12
31
|
/**
|
|
13
32
|
* Owns `RowMenu`'s open/close state, positioning, and keyboard/focus behavior.
|
|
14
33
|
*
|
|
@@ -17,20 +36,8 @@ interface Position {
|
|
|
17
36
|
*/
|
|
18
37
|
export declare function useRowMenu({ itemCount, window, document }: {
|
|
19
38
|
readonly itemCount: number;
|
|
20
|
-
readonly window:
|
|
21
|
-
readonly document:
|
|
22
|
-
}):
|
|
23
|
-
open: boolean;
|
|
24
|
-
position: Position | null;
|
|
25
|
-
triggerRef: import("react").RefObject<HTMLButtonElement | null>;
|
|
26
|
-
menuRef: import("react").RefObject<HTMLDivElement | null>;
|
|
27
|
-
itemRefs: import("react").RefObject<(HTMLButtonElement | null)[]>;
|
|
28
|
-
onTriggerClick: () => void;
|
|
29
|
-
onTriggerKeyDown: (e: KeyboardEvent<HTMLButtonElement>) => void;
|
|
30
|
-
onMenuKeyDown: (e: KeyboardEvent<HTMLDivElement>) => void;
|
|
31
|
-
selectItem: ({ onSelect }: {
|
|
32
|
-
readonly onSelect: () => void;
|
|
33
|
-
}) => void;
|
|
34
|
-
};
|
|
39
|
+
readonly window: RowMenuWindow;
|
|
40
|
+
readonly document: RowMenuDocument;
|
|
41
|
+
}): RowMenuState;
|
|
35
42
|
export {};
|
|
36
43
|
//# sourceMappingURL=RowMenu.hooks.d.ts.map
|
|
@@ -1,6 +1,56 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
/** 8px clearance kept between the menu and the viewport edge on every side it's tested against. */
|
|
3
3
|
const VIEWPORT_MARGIN = 8;
|
|
4
|
+
/**
|
|
5
|
+
* Starts the listeners one open menu needs and returns the matching stop. Called from the menu's
|
|
6
|
+
* callback ref, so both elements are in hand: `trigger` is the button the menu opened from and
|
|
7
|
+
* `menu` is the just-attached popup — neither is ever null here.
|
|
8
|
+
*
|
|
9
|
+
* Positioning runs immediately, at ref-attach time in the commit, i.e. after the (initially
|
|
10
|
+
* off-screen, `visibility: hidden`) menu has actually laid out, so its real dimensions are known
|
|
11
|
+
* before deciding above-vs-below — a plain `useEffect` would run one paint too late and produce a
|
|
12
|
+
* visible jump. Recomputed on scroll and resize too: a portaled menu is a DOM sibling of its anchor,
|
|
13
|
+
* not a child, so it does not track the anchor's position on its own the way an in-flow popup would.
|
|
14
|
+
*
|
|
15
|
+
* Click-outside closes. Scoped to the open window only, same lifecycle discipline as
|
|
16
|
+
* `ConfirmButton`'s armed-only document listeners — a closed, idle `RowMenu` costs nothing beyond
|
|
17
|
+
* its trigger button even with many mounted per table.
|
|
18
|
+
*
|
|
19
|
+
* @complexity O(1) per call and per scroll/resize/mousedown event.
|
|
20
|
+
*/
|
|
21
|
+
function trackOpenMenu({ trigger, menu }, { window, document, onPosition, onDismiss }) {
|
|
22
|
+
function reposition() {
|
|
23
|
+
const rect = trigger.getBoundingClientRect();
|
|
24
|
+
const menuHeight = menu.offsetHeight;
|
|
25
|
+
const menuWidth = menu.offsetWidth;
|
|
26
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
27
|
+
const fitsBelow = spaceBelow >= menuHeight + VIEWPORT_MARGIN;
|
|
28
|
+
const placement = fitsBelow || rect.top < menuHeight + VIEWPORT_MARGIN ? 'below' : 'above';
|
|
29
|
+
// Right-aligned to the trigger by default (the trigger is the table's last column), clamped
|
|
30
|
+
// so it never overflows the viewport's left or right edge.
|
|
31
|
+
const left = Math.min(Math.max(VIEWPORT_MARGIN, rect.right - menuWidth), window.innerWidth - menuWidth - VIEWPORT_MARGIN);
|
|
32
|
+
onPosition({
|
|
33
|
+
top: placement === 'below' ? rect.bottom + 4 : rect.top - 4,
|
|
34
|
+
left,
|
|
35
|
+
placement,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function onDocMouseDown(e) {
|
|
39
|
+
const target = e.target;
|
|
40
|
+
if (menu.contains(target) || trigger.contains(target))
|
|
41
|
+
return;
|
|
42
|
+
onDismiss();
|
|
43
|
+
}
|
|
44
|
+
reposition();
|
|
45
|
+
window.addEventListener('scroll', reposition, true);
|
|
46
|
+
window.addEventListener('resize', reposition);
|
|
47
|
+
document.addEventListener('mousedown', onDocMouseDown);
|
|
48
|
+
return () => {
|
|
49
|
+
window.removeEventListener('scroll', reposition, true);
|
|
50
|
+
window.removeEventListener('resize', reposition);
|
|
51
|
+
document.removeEventListener('mousedown', onDocMouseDown);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
4
54
|
/**
|
|
5
55
|
* Owns `RowMenu`'s open/close state, positioning, and keyboard/focus behavior.
|
|
6
56
|
*
|
|
@@ -8,74 +58,44 @@ const VIEWPORT_MARGIN = 8;
|
|
|
8
58
|
* needs, for `ArrowUp`-to-last-item and wraparound navigation.
|
|
9
59
|
*/
|
|
10
60
|
export function useRowMenu({ itemCount, window, document }) {
|
|
11
|
-
|
|
61
|
+
// The trigger the menu is open against; `null` while closed. Held as state (not just a boolean)
|
|
62
|
+
// so the menu's callback ref below is built with the element already in hand. Read from
|
|
63
|
+
// `triggerRef` at open time: an unattached trigger leaves this `null`, so a menu with nothing to
|
|
64
|
+
// anchor to never opens.
|
|
65
|
+
const [anchor, setAnchor] = useState(null);
|
|
66
|
+
const open = anchor !== null;
|
|
12
67
|
const [position, setPosition] = useState(null);
|
|
13
68
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
14
69
|
const triggerRef = useRef(null);
|
|
15
|
-
const menuRef = useRef(null);
|
|
16
70
|
const itemRefs = useRef([]);
|
|
17
71
|
itemRefs.current = itemRefs.current.slice(0, itemCount);
|
|
18
72
|
function close(returnFocus) {
|
|
19
|
-
|
|
73
|
+
setAnchor(null);
|
|
20
74
|
setPosition(null);
|
|
21
75
|
if (returnFocus)
|
|
22
76
|
triggerRef.current?.focus();
|
|
23
77
|
}
|
|
24
78
|
function openAt(index) {
|
|
25
79
|
setActiveIndex(index);
|
|
26
|
-
|
|
80
|
+
setAnchor(triggerRef.current);
|
|
27
81
|
}
|
|
28
|
-
//
|
|
29
|
-
// `
|
|
30
|
-
// `
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const menuHeight = menuRef.current?.offsetHeight ?? 0;
|
|
42
|
-
const menuWidth = menuRef.current?.offsetWidth ?? 0;
|
|
43
|
-
const spaceBelow = window.innerHeight - rect.bottom;
|
|
44
|
-
const fitsBelow = spaceBelow >= menuHeight + VIEWPORT_MARGIN;
|
|
45
|
-
const placement = fitsBelow || rect.top < menuHeight + VIEWPORT_MARGIN ? 'below' : 'above';
|
|
46
|
-
// Right-aligned to the trigger by default (the trigger is the table's last column), clamped
|
|
47
|
-
// so it never overflows the viewport's left or right edge.
|
|
48
|
-
const left = Math.min(Math.max(VIEWPORT_MARGIN, rect.right - menuWidth), window.innerWidth - menuWidth - VIEWPORT_MARGIN);
|
|
49
|
-
setPosition({
|
|
50
|
-
top: placement === 'below' ? rect.bottom + 4 : rect.top - 4,
|
|
51
|
-
left,
|
|
52
|
-
placement,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
reposition();
|
|
56
|
-
window.addEventListener('scroll', reposition, true);
|
|
57
|
-
window.addEventListener('resize', reposition);
|
|
58
|
-
return () => {
|
|
59
|
-
window.removeEventListener('scroll', reposition, true);
|
|
60
|
-
window.removeEventListener('resize', reposition);
|
|
82
|
+
// Only the open menu renders this ref, so it only exists once there is an anchor. React calls it
|
|
83
|
+
// with the menu element on attach and `null` on detach (React 18 and 19 alike; attach always comes
|
|
84
|
+
// first, so `stop` is set by then). A new anchor or new `window`/`document` ports make a new ref,
|
|
85
|
+
// which React detaches and re-attaches — the same re-subscribe an effect's deps would give.
|
|
86
|
+
const menuRef = useMemo(() => {
|
|
87
|
+
if (!anchor)
|
|
88
|
+
return null;
|
|
89
|
+
let stop;
|
|
90
|
+
return (menu) => {
|
|
91
|
+
if (menu)
|
|
92
|
+
stop = trackOpenMenu({ trigger: anchor, menu }, { window, document, onPosition: setPosition, onDismiss: () => close(false) });
|
|
93
|
+
else
|
|
94
|
+
stop();
|
|
61
95
|
};
|
|
62
|
-
|
|
63
|
-
// Click-outside closes. Scoped to the open window only, same lifecycle discipline as
|
|
64
|
-
// `ConfirmButton`'s armed-only document listeners — a closed, idle `RowMenu` costs nothing beyond
|
|
65
|
-
// its trigger button even with many mounted per table.
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
if (!open)
|
|
68
|
-
return;
|
|
69
|
-
function onDocMouseDown(e) {
|
|
70
|
-
const target = e.target;
|
|
71
|
-
if (menuRef.current?.contains(target) || triggerRef.current?.contains(target))
|
|
72
|
-
return;
|
|
73
|
-
close(false);
|
|
74
|
-
}
|
|
75
|
-
document.addEventListener('mousedown', onDocMouseDown);
|
|
76
|
-
return () => document.removeEventListener('mousedown', onDocMouseDown);
|
|
96
|
+
// `close` only touches state setters and `triggerRef`, all stable across renders.
|
|
77
97
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
-
}, [
|
|
98
|
+
}, [anchor, window, document]);
|
|
79
99
|
// Keeps real DOM focus on the active item (roving focus via `tabIndex={-1}` on every item except
|
|
80
100
|
// the active one) rather than only tracking `activeIndex` in state — arrow-key navigation needs
|
|
81
101
|
// to move the browser's actual focus for a screen reader to announce it.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AdminEntityField } from '../../core/ports/entities.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type EntityTranslate } from './types.js';
|
|
3
3
|
import { type RelationIndex } from './rules.js';
|
|
4
4
|
export declare function EntityField(props: {
|
|
5
5
|
readonly inputId: string;
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Select } from '@jini-ai/ui/admin-widgets';
|
|
3
|
+
import { selectTranslate } from './types.js';
|
|
3
4
|
import { fromDatetimeLocalValue, toDatetimeLocalValue } from './rules.js';
|
|
4
5
|
export function EntityField(props) {
|
|
5
6
|
const { inputId, field, value, translate, disabled, invalid, onChange } = props;
|
|
6
7
|
const common = { id: inputId, disabled, 'aria-invalid': invalid };
|
|
8
|
+
const selectT = selectTranslate({ translate });
|
|
7
9
|
switch (field.kind) {
|
|
8
10
|
case 'json': return _jsx("textarea", { ...common, value: props.text, onChange: (e) => props.onTextChange({ text: e.target.value }) });
|
|
9
11
|
case 'relation': {
|
|
10
12
|
const options = props.relations[field.target]?.options ?? [];
|
|
11
13
|
const selectedId = typeof value === 'string' ? value : '';
|
|
12
14
|
const missing = selectedId !== '' && !options.some((option) => option.id === selectedId);
|
|
13
|
-
return _jsx(Select, { id: inputId, disabled: disabled, value: selectedId, translate:
|
|
15
|
+
return _jsx(Select, { id: inputId, disabled: disabled, value: selectedId, translate: selectT, "aria-label": translate({ key: field.label ?? field.name }), options: [
|
|
14
16
|
{ value: '', label: translate({ key: 'No value' }) },
|
|
15
17
|
...(missing ? [{ value: selectedId, label: `${selectedId} (${translate({ key: 'Unresolved' })})` }] : []),
|
|
16
18
|
...options.map((option) => ({ value: option.id, label: option.title })),
|
|
@@ -19,7 +21,7 @@ export function EntityField(props) {
|
|
|
19
21
|
case 'boolean':
|
|
20
22
|
if (field.required)
|
|
21
23
|
return _jsx("input", { ...common, type: "checkbox", checked: value === true, onChange: (e) => onChange({ value: e.target.checked }) });
|
|
22
|
-
return _jsx(Select, { id: inputId, disabled: disabled, value: value === undefined ? '' : value === true ? 'true' : 'false', translate:
|
|
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: [
|
|
23
25
|
{ value: '', label: translate({ key: 'No value' }) }, { value: 'true', label: translate({ key: 'Yes' }) }, { value: 'false', label: translate({ key: 'No' }) },
|
|
24
26
|
], onChange: (selected) => onChange({ value: selected === '' ? undefined : selected === 'true' }) });
|
|
25
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 }) }) });
|
|
@@ -42,4 +42,13 @@ export declare function entityText({ translate, key, values }: {
|
|
|
42
42
|
readonly key: string;
|
|
43
43
|
readonly values: Readonly<Record<string, string | number>>;
|
|
44
44
|
}): string;
|
|
45
|
+
/**
|
|
46
|
+
* Adapts the host's object-arg {@link EntityTranslate} to the positional `(key) => string` form the
|
|
47
|
+
* shared `Select` widget takes. One adapter for every `Select` an entity form renders, so each one
|
|
48
|
+
* routes its own copy (search box, empty state) through the host dictionary — even the short boolean
|
|
49
|
+
* select, which never shows that copy today.
|
|
50
|
+
*/
|
|
51
|
+
export declare function selectTranslate({ translate }: {
|
|
52
|
+
readonly translate: EntityTranslate;
|
|
53
|
+
}): (key: string) => string;
|
|
45
54
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -2,4 +2,13 @@
|
|
|
2
2
|
export function entityText({ translate, key, values }) {
|
|
3
3
|
return translate({ key: key }).replace(/\{([\w]+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* Adapts the host's object-arg {@link EntityTranslate} to the positional `(key) => string` form the
|
|
7
|
+
* shared `Select` widget takes. One adapter for every `Select` an entity form renders, so each one
|
|
8
|
+
* routes its own copy (search box, empty state) through the host dictionary — even the short boolean
|
|
9
|
+
* select, which never shows that copy today.
|
|
10
|
+
*/
|
|
11
|
+
export function selectTranslate({ translate }) {
|
|
12
|
+
return (key) => translate({ key });
|
|
13
|
+
}
|
|
5
14
|
//# sourceMappingURL=types.js.map
|
|
@@ -3,13 +3,13 @@ import { useController } from '../../../react/use-controller.js';
|
|
|
3
3
|
import { createAccessTokensController } from '../../controllers/access-tokens.controller.js';
|
|
4
4
|
import { createOtherCredentialsController } from '../../controllers/other-credentials.controller.js';
|
|
5
5
|
import { CATEGORIES, credentialLabel, filterCredentials, filterOtherCredentials, otherWritePermission, writePermission } from '../../rules.js';
|
|
6
|
-
import { useSecurityPorts } from './SecurityPorts.hooks.js';
|
|
6
|
+
import { noOtherCredentials, useSecurityPorts } from './SecurityPorts.hooks.js';
|
|
7
7
|
export function useAccessTokensTab({ permissions = [] }, _optional = {}) {
|
|
8
8
|
const ports = useSecurityPorts({});
|
|
9
9
|
const key = permissions.join('\0');
|
|
10
10
|
const canRead = permissions.includes('security.read');
|
|
11
11
|
const access = useController({ create: () => createAccessTokensController({ api: ports.securityApi, permissions }), dependencies: [ports.securityApi, key] }, { start: ({ controller }) => { void controller.load({}); } });
|
|
12
|
-
const other = useController({ create: () => createOtherCredentialsController({ api: ports.otherCredentials ??
|
|
12
|
+
const other = useController({ create: () => createOtherCredentialsController({ api: ports.otherCredentials ?? noOtherCredentials, permissions }), dependencies: [ports.otherCredentials, key] }, { start: ({ controller }) => { void controller.load({}); } });
|
|
13
13
|
const [removal, setRemoval] = useState(null);
|
|
14
14
|
const s = access.snapshot, o = other.snapshot;
|
|
15
15
|
const query = s?.query ?? '', category = s?.category ?? 'all';
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { useController } from '../../../react/use-controller.js';
|
|
2
2
|
import { createRootKeyController } from '../../controllers/root-key.controller.js';
|
|
3
3
|
import { ROOT_KEY_PERMISSION, START_FRESH_CONFIRMATION, isRootKeyLocked } from '../../rules.js';
|
|
4
|
-
import { useSecurityPorts } from './SecurityPorts.hooks.js';
|
|
5
|
-
const unavailable = { async status() { throw new Error('Unavailable'); }, async generate() { throw new Error('Unavailable'); }, async importToken() { throw new Error('Unavailable'); }, async previewStartFresh() { throw new Error('Unavailable'); }, async startFresh() { throw new Error('Unavailable'); } };
|
|
4
|
+
import { unavailableRootKey, useSecurityPorts } from './SecurityPorts.hooks.js';
|
|
6
5
|
export function useRootKeyTab({ permissions = [], params }, _optional = {}) {
|
|
7
6
|
const { rootKey } = useSecurityPorts({});
|
|
8
7
|
const key = permissions.join('\0');
|
|
9
|
-
const { controller, snapshot: s } = useController({ create: () => createRootKeyController({ api: rootKey ??
|
|
8
|
+
const { controller, snapshot: s } = useController({ create: () => createRootKeyController({ api: rootKey ?? unavailableRootKey, permissions }), dependencies: [rootKey, key] }, { start: ({ controller }) => { void controller.load({}); } });
|
|
10
9
|
return { denied: !rootKey || !permissions.includes(ROOT_KEY_PERMISSION), loading: s?.loading ?? true, saving: s?.saving ?? false, status: s?.status, error: s?.error, result: s?.result,
|
|
11
10
|
scopeNotice: typeof params.scopeNotice === 'string' ? params.scopeNotice : 'This key protects saved credentials. Keep its backup separate from the database.',
|
|
12
11
|
token: s?.token ?? '', confirm: s?.confirm ?? '', confirming: s?.confirming ?? false, preview: s?.preview,
|
|
@@ -4,6 +4,10 @@ export interface SecurityPorts {
|
|
|
4
4
|
readonly otherCredentials?: OtherCredentialsPort;
|
|
5
5
|
readonly rootKey?: RootKeyPort;
|
|
6
6
|
}
|
|
7
|
+
/** Stand-ins for optional ports the host did not supply. Hooks always run, so a tab still builds
|
|
8
|
+
* its controller; these keep it inert: nothing to list, every other call refused. */
|
|
9
|
+
export declare const unavailableRootKey: RootKeyPort;
|
|
10
|
+
export declare const noOtherCredentials: OtherCredentialsPort;
|
|
7
11
|
export declare const SecurityPortsContext: import("react").Context<SecurityPorts | null>;
|
|
8
12
|
export declare function useSecurityPorts(_required?: Record<string, never>, _optional?: {}): SecurityPorts;
|
|
9
13
|
//# sourceMappingURL=SecurityPorts.hooks.d.ts.map
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react';
|
|
2
2
|
import { AdminConfigError } from '../../../core/module/index.js';
|
|
3
|
+
const refuse = async () => { throw new Error('Unavailable'); };
|
|
4
|
+
/** Stand-ins for optional ports the host did not supply. Hooks always run, so a tab still builds
|
|
5
|
+
* its controller; these keep it inert: nothing to list, every other call refused. */
|
|
6
|
+
export const unavailableRootKey = { status: refuse, generate: refuse, importToken: refuse, previewStartFresh: refuse, startFresh: refuse };
|
|
7
|
+
export const noOtherCredentials = { async list() { return []; }, replace: refuse, remove: refuse };
|
|
3
8
|
export const SecurityPortsContext = createContext(null);
|
|
4
9
|
export function useSecurityPorts(_required = {}, _optional = {}) { const ports = useContext(SecurityPortsContext); if (!ports)
|
|
5
10
|
throw new AdminConfigError({ issues: ['React scope unavailable: security'] }); return ports; }
|
|
@@ -5,10 +5,10 @@ import { useSourceControlPorts } from './SourceControlPorts.hooks.js';
|
|
|
5
5
|
export function useProvidersTab({ permissions = [] }, _optional = {}) {
|
|
6
6
|
const ports = useSourceControlPorts({}), key = permissions.join('\0'), canRead = permissions.includes('source-control.read'), canWrite = canRead && permissions.includes('source-control.credentials.write');
|
|
7
7
|
const { controller, snapshot: state } = useController({ create: () => createSourceControlController({ api: ports.sourceControlApi, permissions }), dependencies: [ports.sourceControlApi, key] }, { start: ({ controller }) => { void controller.load({}); } });
|
|
8
|
-
const infos = sourceControlProviders({ providers: state?.providers ?? [], credentials
|
|
9
|
-
const first = infos.find(p => p.listed && !defaultSourceControlCredential({ credentials
|
|
8
|
+
const credentials = state?.credentials ?? [], infos = sourceControlProviders({ providers: state?.providers ?? [], credentials });
|
|
9
|
+
const first = infos.find(p => p.listed && !defaultSourceControlCredential({ credentials, providerId: p.id }))?.id;
|
|
10
10
|
const rows = infos.map(info => {
|
|
11
|
-
const saved = defaultSourceControlCredential({ credentials
|
|
11
|
+
const saved = defaultSourceControlCredential({ credentials, providerId: info.id }), draft = state?.drafts[info.id] ?? blankSourceControlDraft({});
|
|
12
12
|
return { id: info.id, label: info.label, heading: saved ? info.label : `Connect ${info.label}`, saved, defaultOpen: info.id === first, canEdit: canWrite && info.listed, unlisted: !info.listed, connected: !!saved,
|
|
13
13
|
token: draft.token, tokenLabel: `${info.label} ${info.tokenLabel}`, hint: saved ? 'Leave blank to keep the current token.' : 'Once saved, the token is never displayed again.', saving: draft.saving, error: draft.error,
|
|
14
14
|
disabled: !canWrite || !sourceControlReady({ info, draft }), help: info.help, tokenPageUrl: safeSourceControlUrl({ url: info.tokenPageUrl }), hasGuidance: !!info.help || !!safeSourceControlUrl({ url: info.tokenPageUrl }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/admin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Composable admin surface for Jini-hosted products: framework-free contracts, panel registry, and route model in `/core`; React shell and panels in `/react`. Import only the layers you need.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -282,10 +282,10 @@
|
|
|
282
282
|
"react-dom": "^19.2.0"
|
|
283
283
|
},
|
|
284
284
|
"dependencies": {
|
|
285
|
+
"@jini-ai/ui": "^0.4.3",
|
|
285
286
|
"@jini-ai/agentic": "^0.4.1",
|
|
286
|
-
"@jini-ai/
|
|
287
|
-
"@jini-ai/ui-kit": "^0.1.
|
|
288
|
-
"@jini-ai/agent-plugins": "^0.4.1"
|
|
287
|
+
"@jini-ai/agent-plugins": "^0.4.1",
|
|
288
|
+
"@jini-ai/ui-kit": "^0.1.1"
|
|
289
289
|
},
|
|
290
290
|
"scripts": {
|
|
291
291
|
"build": "tsc -p tsconfig.json",
|