@jini-ai/admin 0.6.15-rc.2 → 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/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/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/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 +7 -1
- 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
|
@@ -5,10 +5,8 @@ import { settingsFormFromField } from "../../field-settings.js";
|
|
|
5
5
|
export function useFieldAttributesDialog(props, _optional = {}) {
|
|
6
6
|
const { controller: stateController, snapshot: stateSnapshot } = useController({ create: () => createFieldAttributesDialogController({ field: props.field }), dependencies: [] });
|
|
7
7
|
const state = stateSnapshot ?? { className: props.field.className ?? "", rows: [], settings: settingsFormFromField({ field: props.field }), error: null };
|
|
8
|
-
const className = state
|
|
8
|
+
const { className, rows, error } = state;
|
|
9
9
|
const setClassName = (value) => stateController?.update({ key: "className", value });
|
|
10
|
-
const rows = state.rows;
|
|
11
|
-
const error = state.error;
|
|
12
10
|
function updateRow(rowId, patch) { stateController?.updateRow({ rowId, patch }); }
|
|
13
11
|
function removeRow(rowId) { stateController?.removeRow({ rowId }); }
|
|
14
12
|
function addRow() { stateController?.addRow({}); }
|
|
@@ -8,24 +8,24 @@ import { useDirtyGuard } from "@jini-ai/ui/panel-kit";
|
|
|
8
8
|
import { formEditorBaseline, formEditorDraft } from "../../form-editor-dirty.js";
|
|
9
9
|
import { useFetchMutation, useFetchQuery } from "@jini-ai/ui/fetch-query";
|
|
10
10
|
import { FORM_TABS, KEYS, existingFieldIdsOf, nextTabIndex, visibleFormEditorError } from "../../rules.js";
|
|
11
|
+
/** Ordinary Builder saves keep their existing endpoint/permissions; a mode change is explicit. */
|
|
12
|
+
function authoringPatch({ mode, html, savedMode }) {
|
|
13
|
+
if (mode === "html")
|
|
14
|
+
return { mode, html };
|
|
15
|
+
return savedMode === "html" ? { mode } : {};
|
|
16
|
+
}
|
|
11
17
|
export function useFormEditor(props, { clipboard } = {}) {
|
|
12
18
|
const { port, navigate, t } = props;
|
|
13
19
|
const { tab } = props;
|
|
14
20
|
const isNew = props.formId === "new";
|
|
15
21
|
const { controller: stateController, snapshot: stateSnapshot } = useController({ create: () => createFormEditorController({}), dependencies: [] });
|
|
16
22
|
const state = stateSnapshot ?? initialFormEditorState({});
|
|
17
|
-
const form = state
|
|
23
|
+
const { form, mode, html, copyFeedback, name, slug, fields, notify } = state;
|
|
18
24
|
const setForm = (value) => stateController?.update({ key: "form", value });
|
|
19
|
-
const mode = state.mode;
|
|
20
|
-
const html = state.html;
|
|
21
25
|
const setHtml = (value) => stateController?.update({ key: "html", value });
|
|
22
|
-
const copyFeedback = state.copyFeedback;
|
|
23
26
|
const setCopyFeedback = (value) => stateController?.update({ key: "copyFeedback", value });
|
|
24
|
-
const name = state.name;
|
|
25
27
|
const setName = (value) => stateController?.update({ key: "name", value });
|
|
26
|
-
const slug = state.slug;
|
|
27
28
|
const setSlug = (value) => stateController?.update({ key: "slug", value });
|
|
28
|
-
const fields = state.fields;
|
|
29
29
|
const setFields = (value) => stateController?.update({ key: "fields", value });
|
|
30
30
|
// No UI edits this (owner ask 2026-09-22 removed the notify checkbox/recipients input from the
|
|
31
31
|
// Fields tab — a future outside mail integration will design its own way to set it). Seeded from
|
|
@@ -33,7 +33,6 @@ export function useFormEditor(props, { clipboard } = {}) {
|
|
|
33
33
|
// wipes out a `notify` setting that predates this UI removal. Lives in the section controller, not a
|
|
34
34
|
// `const`, only so `handleSave`'s existing-form branch can resync it from the write's own response
|
|
35
35
|
// exactly like `form`/`name`/`slug`/`fields` already do below.
|
|
36
|
-
const notify = state.notify;
|
|
37
36
|
// Roving-tabindex focus targets for the tab strip below, indexed the same as `FORM_TABS` — see
|
|
38
37
|
// `nextTabIndex`'s doc comment for why the index math itself lives outside the component.
|
|
39
38
|
const tabRefs = useRef([]);
|
|
@@ -106,7 +105,7 @@ export function useFormEditor(props, { clipboard } = {}) {
|
|
|
106
105
|
}
|
|
107
106
|
async function handleSave() {
|
|
108
107
|
// Ordinary Builder saves keep their existing endpoint/permissions. A mode change is explicit.
|
|
109
|
-
const authoring =
|
|
108
|
+
const authoring = authoringPatch({ mode, html, savedMode: form?.mode });
|
|
110
109
|
try {
|
|
111
110
|
if (isNew) {
|
|
112
111
|
// `notify` here is always this hook's own blank default — a new form has no earlier value
|
|
@@ -56,13 +56,10 @@ export function useFormSubmissionDetail(props, _optional = {}) {
|
|
|
56
56
|
// The delete's own failure outranks a background load-refresh failure — flat `if`s rather than a
|
|
57
57
|
// nested ternary, per `@jini-ai/ui/fetch-query`'s `resolveFetchQueryStatus` doc on why the two carry
|
|
58
58
|
// a different complexity-gate weight for the same branch count.
|
|
59
|
-
let error = null;
|
|
59
|
+
let error = list.error ? describeApiError(list.error, "failed to load submission") : null;
|
|
60
60
|
if (deleteMutation.error) {
|
|
61
61
|
error = describeTrashError({ error: deleteMutation.error, fallback: "delete failed", versionChangedMessage: t("This item changed since you loaded it. Reload and try again.") }).message;
|
|
62
62
|
}
|
|
63
|
-
else if (list.error) {
|
|
64
|
-
error = describeApiError(list.error, "failed to load submission");
|
|
65
|
-
}
|
|
66
63
|
return {
|
|
67
64
|
submission,
|
|
68
65
|
error,
|
|
@@ -17,15 +17,11 @@ export function useFormSubmissions(props, _optional = {}) {
|
|
|
17
17
|
});
|
|
18
18
|
const { controller: stateController, snapshot: stateSnapshot } = useController({ create: () => createFormSubmissionsController({}), dependencies: [] });
|
|
19
19
|
const state = stateSnapshot ?? initialFormSubmissionsState({});
|
|
20
|
-
const morePages = state
|
|
20
|
+
const { morePages, moreCursor, moreError, selectedId, loadingMore } = state;
|
|
21
21
|
const setMorePages = (value) => stateController?.update({ key: "morePages", value });
|
|
22
|
-
const moreCursor = state.moreCursor;
|
|
23
22
|
const setMoreCursor = (value) => stateController?.update({ key: "moreCursor", value });
|
|
24
|
-
const moreError = state.moreError;
|
|
25
23
|
const setMoreError = (value) => stateController?.update({ key: "moreError", value });
|
|
26
|
-
const selectedId = state.selectedId;
|
|
27
24
|
const setSelectedId = (value) => stateController?.update({ key: "selectedId", value });
|
|
28
|
-
const loadingMore = state.loadingMore;
|
|
29
25
|
const setLoadingMore = (value) => stateController?.update({ key: "loadingMore", value });
|
|
30
26
|
// The "latest call wins" guard for accumulated pages — see the file header. `loadingMoreRef` is
|
|
31
27
|
// the synchronous half (blocks a same-tick double `loadMore`); `pageSettlement` is the async half
|
|
@@ -15,7 +15,7 @@ export function useFormsList(deps, { locale = "en" } = {}) {
|
|
|
15
15
|
useEffect(() => events?.subscribe({ onRefresh: invalidateList }), [events, invalidateList]);
|
|
16
16
|
const { controller: stateController, snapshot: stateSnapshot } = useController({ create: () => createFormsListController({}), dependencies: [] });
|
|
17
17
|
const state = stateSnapshot ?? initialFormsListState({});
|
|
18
|
-
const rowSavingId = state
|
|
18
|
+
const { rowSavingId, pendingDelete } = state;
|
|
19
19
|
const setRowSavingId = (value) => stateController?.update({ key: "rowSavingId", value });
|
|
20
20
|
const toggleMutation = useFetchMutation({
|
|
21
21
|
run: ({ input: form }) => port.updateFormDefinition({ id: form.id }, { status: form.status === "active" ? "disabled" : "active" }),
|
|
@@ -25,7 +25,6 @@ export function useFormsList(deps, { locale = "en" } = {}) {
|
|
|
25
25
|
// The form a `RowMenu` "Delete" selection is asking to confirm — `null` when the `ConfirmDialog`
|
|
26
26
|
// is closed. `ConfirmDialog` stays mounted unconditionally in `FormsList.tsx`; this is what drives
|
|
27
27
|
// its `open` prop — same shape `use-posts.hooks.ts`'s `pendingDelete` uses for its own row delete.
|
|
28
|
-
const pendingDelete = state.pendingDelete;
|
|
29
28
|
const setPendingDelete = (value) => stateController?.update({ key: "pendingDelete", value });
|
|
30
29
|
const deleteMutation = useFetchMutation({
|
|
31
30
|
run: ({ input: form }) => trash.trash({ type: "form", id: form.id }),
|
package/dist/forms/rules.js
CHANGED
|
@@ -291,8 +291,13 @@ export const ATTRIBUTE_NAME_SUGGESTIONS = [
|
|
|
291
291
|
* stable identity — same `_rowId`/module-counter pattern `collections/rules.ts`'s
|
|
292
292
|
* `EditFieldsDialog` support uses for its own draft field rows. */
|
|
293
293
|
let nextAttrRowId = 0;
|
|
294
|
+
function takeAttrRowId() {
|
|
295
|
+
const id = nextAttrRowId;
|
|
296
|
+
nextAttrRowId += 1;
|
|
297
|
+
return id;
|
|
298
|
+
}
|
|
294
299
|
export function attrRowsFromField({ field }, _optional = {}) {
|
|
295
|
-
return Object.entries(field.attributes ?? {}).map(([name, value]) => ({ _rowId:
|
|
300
|
+
return Object.entries(field.attributes ?? {}).map(([name, value]) => ({ _rowId: takeAttrRowId(), name, value }));
|
|
296
301
|
}
|
|
297
302
|
/** @complexity O(n) in `rows.length`. */
|
|
298
303
|
export function updateAttrRow({ rows, rowId, patch }, _optional = {}) {
|
|
@@ -304,7 +309,7 @@ export function removeAttrRow({ rows, rowId }, _optional = {}) {
|
|
|
304
309
|
}
|
|
305
310
|
/** @complexity O(n) in `rows.length`. */
|
|
306
311
|
export function addAttrRow({ rows }, _optional = {}) {
|
|
307
|
-
return [...rows, { _rowId:
|
|
312
|
+
return [...rows, { _rowId: takeAttrRowId(), name: "", value: "" }];
|
|
308
313
|
}
|
|
309
314
|
/**
|
|
310
315
|
* Validates + shapes `FieldAttributesDialog`'s draft into the `Partial<AdminFormField>` patch
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trimCharsEnd } from '@jini-ai/core/text';
|
|
1
2
|
import { queryMedia } from '../rules.js';
|
|
2
3
|
const METADATA_KEYS = ['title', 'slug', 'alt', 'caption', 'credit', 'width', 'height', 'cssClass', 'htmlAttributes'];
|
|
3
4
|
/** Only metadata keys with a defined value; null stays, so a patch can clear a nullable field. */
|
|
@@ -6,12 +7,13 @@ function pickMetadata({ patch }) {
|
|
|
6
7
|
}
|
|
7
8
|
/** Decoded length of a base64 string: 3 bytes per 4 chars, minus trailing padding. */
|
|
8
9
|
function base64ByteSize({ dataBase64 }) {
|
|
9
|
-
const padding = dataBase64.
|
|
10
|
+
const padding = dataBase64.length - trimCharsEnd({ text: dataBase64, chars: '=' }).length;
|
|
10
11
|
return Math.max(0, Math.floor(dataBase64.length * 3 / 4) - padding);
|
|
11
12
|
}
|
|
12
13
|
export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().toISOString() } = {}) {
|
|
13
14
|
const items = new Map(assets.map((item) => [item.id, Object.freeze({ ...item })]));
|
|
14
15
|
let sequence = 0;
|
|
16
|
+
const nextSequence = () => { sequence += 1; return sequence; };
|
|
15
17
|
function check(options) {
|
|
16
18
|
options.signal?.throwIfAborted();
|
|
17
19
|
}
|
|
@@ -30,7 +32,7 @@ export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().t
|
|
|
30
32
|
// The REAL server ignores the declared string and stores sniffContentType(bytes) instead.
|
|
31
33
|
// This fake has no bytes to sniff — its input is an opaque base64 string a test made up —
|
|
32
34
|
// so it echoes what the caller declared. Tests needing disagreement must seed assets directly.
|
|
33
|
-
return { byteSize: base64ByteSize(input), contentType: input.contentType, sha256: `memory-content-${
|
|
35
|
+
return { byteSize: base64ByteSize(input), contentType: input.contentType, sha256: `memory-content-${nextSequence()}` };
|
|
34
36
|
}
|
|
35
37
|
return {
|
|
36
38
|
replaceSupported: true,
|
|
@@ -43,7 +45,7 @@ export function createMemoryMediaApi({ assets = [] }, { now = () => new Date().t
|
|
|
43
45
|
check(options);
|
|
44
46
|
let id;
|
|
45
47
|
do {
|
|
46
|
-
id = `memory-${
|
|
48
|
+
id = `memory-${nextSequence()}`;
|
|
47
49
|
} while (items.has(id));
|
|
48
50
|
const timestamp = now();
|
|
49
51
|
return put({
|
|
@@ -52,7 +52,8 @@ export function createLibraryController({ api }, { query = {} } = {}) {
|
|
|
52
52
|
// Retry reads only, never destructive writes. Keep trying at a capped delay so
|
|
53
53
|
// recovery needs no manual repair, while an offline service isn't hammered.
|
|
54
54
|
if (isRetryableMediaError({ error })) {
|
|
55
|
-
const delay = Math.min(1000 * 2 ** Math.min(retryAttempt
|
|
55
|
+
const delay = Math.min(1000 * 2 ** Math.min(retryAttempt, 5), 30000);
|
|
56
|
+
retryAttempt += 1;
|
|
56
57
|
retryTimer = setTimeout(() => { retryTimer = null; void load(); }, delay);
|
|
57
58
|
}
|
|
58
59
|
}
|
|
@@ -63,7 +64,8 @@ export function createLibraryController({ api }, { query = {} } = {}) {
|
|
|
63
64
|
readAbort?.abort();
|
|
64
65
|
const abort = new AbortController();
|
|
65
66
|
readAbort = abort;
|
|
66
|
-
|
|
67
|
+
generation += 1;
|
|
68
|
+
const ticket = generation;
|
|
67
69
|
store.set({ patch: { loading: true, error: null } });
|
|
68
70
|
try {
|
|
69
71
|
const all = await api.list({}, { signal: abort.signal });
|
|
@@ -12,7 +12,8 @@ export function createProvidersController({ api }, _optional = {}) {
|
|
|
12
12
|
async function load(_required = {}, _optional = {}) {
|
|
13
13
|
if (store.signal.aborted)
|
|
14
14
|
return;
|
|
15
|
-
|
|
15
|
+
generation += 1;
|
|
16
|
+
const ticket = generation;
|
|
16
17
|
store.set({ patch: { loading: true } });
|
|
17
18
|
try {
|
|
18
19
|
const items = await api.list({}, { signal: store.signal });
|
|
@@ -2,8 +2,18 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { Badge, Button, Menu } from '@jini-ai/ui-kit/react';
|
|
3
3
|
import { useMediaCard } from '../hooks/MediaCard.hooks.js';
|
|
4
4
|
import { EyeIcon, ExpandIcon, DocumentIcon } from './MediaIcons.js';
|
|
5
|
+
/** Image (expandable when a preview exists), then video, then a download placeholder. */
|
|
6
|
+
function previewMedia(vm) {
|
|
7
|
+
if (vm.image) {
|
|
8
|
+
const img = _jsx("img", { className: "jini-media-card-media", src: vm.src, alt: vm.alt, loading: "lazy", onError: vm.onImageError });
|
|
9
|
+
return vm.imagePreview ? _jsxs(Button, { className: "jini-media-card-expand jini-media-card-expand-fill", variant: "ghost", attrs: vm.previewAttrs, onPress: vm.preview, children: [img, _jsx(ExpandIcon, {})] }) : img;
|
|
10
|
+
}
|
|
11
|
+
if (vm.video)
|
|
12
|
+
return _jsx("video", { className: "jini-media-card-media", src: vm.src, controls: true, preload: "metadata", "aria-label": vm.alt, onError: vm.onVideoError });
|
|
13
|
+
return _jsxs("div", { className: "jini-media-card-placeholder", children: [_jsx(DocumentIcon, {}), _jsx("p", { className: "jini-media-card-placeholder-text", children: "Preview not available" }), vm.src && _jsx("a", { className: "jini-media-card-placeholder-link", href: vm.src, target: "_blank", rel: "noreferrer", children: "Download original" })] });
|
|
14
|
+
}
|
|
5
15
|
function PreviewMarkup(vm, _optional = {}) {
|
|
6
|
-
return _jsxs(_Fragment, { children: [
|
|
16
|
+
return _jsxs(_Fragment, { children: [previewMedia(vm), vm.onEdit && _jsxs(Button, { className: "jini-media-card-edit", variant: "ghost", attrs: vm.editAttrs, onPress: vm.onEdit, disabled: vm.busy, children: [_jsx(EyeIcon, {}), _jsx("span", { className: "jini-visually-hidden", children: "Edit metadata" })] }), vm.canPreview && !vm.imagePreview && _jsxs(Button, { className: "jini-media-card-expand", variant: "ghost", attrs: vm.previewAttrs, onPress: vm.preview, children: [_jsx(ExpandIcon, {}), _jsx("span", { className: "jini-visually-hidden", children: "View larger" })] })] });
|
|
7
17
|
}
|
|
8
18
|
/** The lightbox uses the same fallback chain without the grid's metadata and actions. */
|
|
9
19
|
export function MediaPreview(props, _optional = {}) {
|
|
@@ -4,6 +4,14 @@ import { useController } from '../../../core/react/use-controller.js';
|
|
|
4
4
|
import { createEditMediaController } from '../../controllers/edit-media.controller.js';
|
|
5
5
|
import { canReplaceMedia, mediaEmbedSnippet, parseOptionalPixelSize, safeMediaUrl } from '../../rules.js';
|
|
6
6
|
import { parseMediaHtmlAttributes, describeMediaHtmlAttributeError } from '../../html-attributes.js';
|
|
7
|
+
/** Draft value for an edited field: pixel sizes parse, optional text clears to null when blank. */
|
|
8
|
+
function draftFieldValue(key, value) {
|
|
9
|
+
if (key === 'width' || key === 'height')
|
|
10
|
+
return parseOptionalPixelSize({ value });
|
|
11
|
+
if (key === 'cssClass' || key === 'htmlAttributes')
|
|
12
|
+
return value.trim() === '' ? null : value;
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
7
15
|
export function useEditMediaPanel(props, _optional = {}) {
|
|
8
16
|
const cached = useMediaLibraryQuery({ api: props.api }, { enabled: false });
|
|
9
17
|
const { controller, snapshot } = useController({
|
|
@@ -15,8 +23,12 @@ export function useEditMediaPanel(props, _optional = {}) {
|
|
|
15
23
|
const mounted = useRef(false);
|
|
16
24
|
useEffect(() => {
|
|
17
25
|
mounted.current = true;
|
|
18
|
-
return () => {
|
|
19
|
-
|
|
26
|
+
return () => {
|
|
27
|
+
mounted.current = false;
|
|
28
|
+
for (const timer of timers.current.values())
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
timers.current.clear();
|
|
31
|
+
};
|
|
20
32
|
}, []);
|
|
21
33
|
const saving = !snapshot || snapshot.saving;
|
|
22
34
|
const titleRef = useRef(null);
|
|
@@ -53,9 +65,7 @@ export function useEditMediaPanel(props, _optional = {}) {
|
|
|
53
65
|
...(key === 'title' ? { 'data-jini-autofocus': true } : {}),
|
|
54
66
|
...(key === 'htmlAttributes' ? { 'aria-invalid': !!hint } : {}) },
|
|
55
67
|
onValueChange: ({ value }, _optional = {}) => {
|
|
56
|
-
|
|
57
|
-
: key === 'cssClass' || key === 'htmlAttributes' ? value.trim() === '' ? null : value : value;
|
|
58
|
-
controller?.setDraft({ patch: { [key]: next } });
|
|
68
|
+
controller?.setDraft({ patch: { [key]: draftFieldValue(key, value) } });
|
|
59
69
|
},
|
|
60
70
|
}));
|
|
61
71
|
// Server-computed public URL, never the authenticated original route. Trashed assets
|
|
@@ -65,6 +75,7 @@ export function useEditMediaPanel(props, _optional = {}) {
|
|
|
65
75
|
const embed = mediaEmbedSnippet({ slug: props.item.slug });
|
|
66
76
|
// Visible text is the legacy "Copy"/"Copied" on every row; the accessible name keeps
|
|
67
77
|
// the value's noun so three identical buttons stay distinguishable, even mid-feedback.
|
|
78
|
+
const markCopied = (id, value) => setCopied(current => ({ ...current, [id]: value }));
|
|
68
79
|
const copies = [
|
|
69
80
|
{ id: 'url', label: 'File URL', value: publicUrl, noun: 'URL' },
|
|
70
81
|
{ id: 'embed', label: 'Embed code', value: embed, noun: 'embed code' },
|
|
@@ -78,11 +89,11 @@ export function useEditMediaPanel(props, _optional = {}) {
|
|
|
78
89
|
await navigator.clipboard.writeText(row.value);
|
|
79
90
|
if (!mounted.current)
|
|
80
91
|
return;
|
|
81
|
-
|
|
92
|
+
markCopied(row.id, true);
|
|
82
93
|
clearTimeout(timers.current.get(row.id));
|
|
83
94
|
timers.current.set(row.id, setTimeout(() => {
|
|
84
95
|
timers.current.delete(row.id);
|
|
85
|
-
|
|
96
|
+
markCopied(row.id, false);
|
|
86
97
|
}, 1500));
|
|
87
98
|
}
|
|
88
99
|
catch {
|
|
@@ -7,6 +7,7 @@ import { settleFromCache, useSharedMediaLibrary } from './MediaPage.hooks.js';
|
|
|
7
7
|
import { useMediaPorts } from './MediaPorts.hooks.js';
|
|
8
8
|
import { mediaMessagesEn } from '../../messages.en.js';
|
|
9
9
|
import { canRestoreMedia } from '../../rules.js';
|
|
10
|
+
import { omitKeys } from '../../../core/omit-keys.js';
|
|
10
11
|
const EMPTY_LABELS = {
|
|
11
12
|
images: 'No images yet. Upload an image to see it here.',
|
|
12
13
|
videos: 'No videos yet. Upload a video to see it here.',
|
|
@@ -52,7 +53,7 @@ function purgeDialogView({ snapshot }, _optional = {}) {
|
|
|
52
53
|
function queryHandlers({ controller }, _optional = {}) {
|
|
53
54
|
return {
|
|
54
55
|
onStatus: ({ value }) => {
|
|
55
|
-
const
|
|
56
|
+
const query = omitKeys(controller?.getSnapshot().query ?? {}, ['status']);
|
|
56
57
|
void controller?.setQuery({ query: { ...query, ...(value === 'active' || value === 'trashed' ? { status: value } : {}) } });
|
|
57
58
|
},
|
|
58
59
|
onSearch: ({ value: search }) => {
|
|
@@ -183,7 +184,7 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
|
|
|
183
184
|
const item = controller?.getSnapshot().pendingPurge;
|
|
184
185
|
// The controller refuses to purge without a pending item, so there is nothing to announce.
|
|
185
186
|
if (!can.canPurge || !controller || !item)
|
|
186
|
-
return;
|
|
187
|
+
return undefined;
|
|
187
188
|
const index = items.findIndex(row => row.id === item.id);
|
|
188
189
|
return controller.confirmPurge({ confirmed: true }).then(saved => {
|
|
189
190
|
if (saved) {
|
|
@@ -34,8 +34,10 @@ async function saveProviderSettings({ port, item, entry, signal }, _optional = {
|
|
|
34
34
|
await port.saveSettings({ id: item.id, ...next }, { signal });
|
|
35
35
|
}
|
|
36
36
|
async function saveProvider({ port, item, signal }, { entry } = {}) {
|
|
37
|
-
if (!entry)
|
|
38
|
-
|
|
37
|
+
if (!entry) {
|
|
38
|
+
await removeIfStored({ port, item, signal });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
39
41
|
await saveProviderSettings({ port, item, entry, signal });
|
|
40
42
|
if (entry.apiKey?.trim())
|
|
41
43
|
await port.saveCredential({ id: item.id, credential: entry.apiKey }, { signal });
|
|
@@ -57,17 +59,21 @@ async function saveSequentially({ port, providers, lastRead, signal }, _optional
|
|
|
57
59
|
function rowStatus({ entry, pending }, _optional = {}) {
|
|
58
60
|
const saved = isMarkerOnlyEntry(entry);
|
|
59
61
|
const mask = saved ? maskedKeyLabel(entry) : null;
|
|
62
|
+
const unsavedLabel = pending ? 'Unsaved' : null;
|
|
60
63
|
return {
|
|
61
64
|
mask,
|
|
62
|
-
statusLabel: saved ? `Saved (${mask})` :
|
|
65
|
+
statusLabel: saved ? `Saved (${mask})` : unsavedLabel,
|
|
63
66
|
statusClassName: saved ? 'jini-field-status-badge jini-field-status-badge-success' : 'jini-field-status-badge',
|
|
64
67
|
};
|
|
65
68
|
}
|
|
69
|
+
/** Names the provider default an empty endpoint falls back to, when it has one. */
|
|
70
|
+
function defaultBaseUrlHint({ entry, item }) {
|
|
71
|
+
return !entry.baseUrl?.trim() && item.defaultBaseUrl ? `Uses ${item.defaultBaseUrl} by default.` : null;
|
|
72
|
+
}
|
|
66
73
|
function baseUrlHint({ entry, item }, _optional = {}) {
|
|
67
74
|
const invalid = isProviderBaseUrlInvalid(entry);
|
|
68
75
|
return {
|
|
69
|
-
invalid, hint: invalid ? 'Enter an absolute http:// or https:// URL that is not a private or internal address.'
|
|
70
|
-
: !entry.baseUrl?.trim() && item.defaultBaseUrl ? `Uses ${item.defaultBaseUrl} by default.` : null,
|
|
76
|
+
invalid, hint: invalid ? 'Enter an absolute http:// or https:// URL that is not a private or internal address.' : defaultBaseUrlHint({ entry, item }),
|
|
71
77
|
hintClassName: invalid ? 'jini-field-hint jini-hint-error' : 'jini-field-hint',
|
|
72
78
|
hintRole: invalid ? 'alert' : undefined,
|
|
73
79
|
ariaInvalid: invalid || undefined,
|
|
@@ -177,10 +183,14 @@ export function useProvidersTab(_required = {}, _optional = {}) {
|
|
|
177
183
|
const catalog = mediaProviders?.catalog ?? discoveredCatalog;
|
|
178
184
|
const pinnedIds = mediaProviders?.pinnedProviderIds ?? [];
|
|
179
185
|
const ordered = sortProvidersByConfigured(catalog, editor.providers, pinnedIds);
|
|
180
|
-
const toggleKey = (id) => setVisibleKeys(current => {
|
|
181
|
-
next
|
|
182
|
-
|
|
183
|
-
|
|
186
|
+
const toggleKey = (id) => setVisibleKeys(current => {
|
|
187
|
+
const next = new Set(current);
|
|
188
|
+
if (next.has(id))
|
|
189
|
+
next.delete(id);
|
|
190
|
+
else
|
|
191
|
+
next.add(id);
|
|
192
|
+
return next;
|
|
193
|
+
});
|
|
184
194
|
const rows = ordered.map((item, index) => providerRow({ item, entry: editor.providers[item.id] ?? {}, editor, visible: visibleKeys.has(item.id), toggle: () => toggleKey(item.id) }, { divider: index > 0 && !pinnedIds.includes(item.id) && pinnedIds.includes(ordered[index - 1].id), disabled: !mediaProviders }));
|
|
185
195
|
return { rows, ...editorView({ editor, port: mediaProviders }) };
|
|
186
196
|
}
|
package/dist/media/rules.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trimChars } from '@jini-ai/core/text';
|
|
1
2
|
/** Falls back to "all" for an absent or unrecognized ?tab= value — don't trust a raw
|
|
2
3
|
* query value: a stale link or a typo must not blank the panel. */
|
|
3
4
|
export function resolveActiveTab({ tabId }, _optional = {}) {
|
|
@@ -138,11 +139,13 @@ export function mediaEmbedSnippet({ slug }, _optional = {}) {
|
|
|
138
139
|
export function mediaRowHandles({ media }, _optional = {}) {
|
|
139
140
|
const used = new Set();
|
|
140
141
|
return media.map((item, index) => {
|
|
141
|
-
const slug = item.id.toLowerCase().replace(/[^a-z0-9]+/g, '-')
|
|
142
|
+
const slug = trimChars({ text: item.id.toLowerCase().replace(/[^a-z0-9]+/g, '-'), chars: '-' });
|
|
142
143
|
const preferred = `media-item-${slug || index + 1}`;
|
|
143
144
|
let handle = preferred, suffix = 2;
|
|
144
|
-
while (used.has(handle))
|
|
145
|
-
handle = `${preferred}-${suffix
|
|
145
|
+
while (used.has(handle)) {
|
|
146
|
+
handle = `${preferred}-${suffix}`;
|
|
147
|
+
suffix += 1;
|
|
148
|
+
}
|
|
146
149
|
used.add(handle);
|
|
147
150
|
return handle;
|
|
148
151
|
});
|
|
@@ -18,13 +18,32 @@ export function createMemoryPlaygroundTargets(_required, { onListenerError } = {
|
|
|
18
18
|
}
|
|
19
19
|
return {
|
|
20
20
|
getSnapshot: (_required = {}, _optional = {}) => snapshot,
|
|
21
|
-
subscribe({ listener }, _optional = {}) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
subscribe({ listener }, _optional = {}) {
|
|
22
|
+
if (!disposed)
|
|
23
|
+
listeners.add(listener);
|
|
24
|
+
return () => listeners.delete(listener);
|
|
25
|
+
},
|
|
26
|
+
register({ target }, _optional = {}) {
|
|
27
|
+
if (disposed)
|
|
28
|
+
throw new Error('Playground target scope disposed');
|
|
29
|
+
const ticket = {};
|
|
30
|
+
owner = ticket;
|
|
31
|
+
publish(target);
|
|
32
|
+
return { release(_required = {}, _optional = {}) {
|
|
33
|
+
if (owner !== ticket)
|
|
34
|
+
return;
|
|
35
|
+
owner = null;
|
|
36
|
+
publish(null);
|
|
37
|
+
} };
|
|
38
|
+
},
|
|
39
|
+
dispose(_required = {}, _optional = {}) {
|
|
40
|
+
if (disposed)
|
|
41
|
+
return;
|
|
42
|
+
disposed = true;
|
|
43
|
+
owner = null;
|
|
44
|
+
publish(null);
|
|
45
|
+
listeners.clear();
|
|
46
|
+
},
|
|
28
47
|
};
|
|
29
48
|
}
|
|
30
49
|
//# sourceMappingURL=memory.js.map
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/** Run with an isolated bus; this temporarily registers and releases test targets. */
|
|
2
2
|
export function runPlaygroundApiConformance({ targets }, _optional = {}) {
|
|
3
3
|
const checks = [];
|
|
4
|
-
const assert = (ok, name) => {
|
|
5
|
-
|
|
4
|
+
const assert = (ok, name) => {
|
|
5
|
+
if (!ok)
|
|
6
|
+
throw new Error(`Playground conformance: ${name}`);
|
|
7
|
+
checks.push(name);
|
|
8
|
+
};
|
|
6
9
|
const a = {}, b = {};
|
|
7
10
|
let count = 0;
|
|
8
|
-
const stop = targets.subscribe({ listener: () => count
|
|
11
|
+
const stop = targets.subscribe({ listener: () => { count += 1; } });
|
|
9
12
|
const first = targets.register({ target: a });
|
|
10
13
|
let second;
|
|
11
14
|
try {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react';
|
|
2
2
|
import { AdminConfigError } from '../../../core/module/index.js';
|
|
3
3
|
export const PlaygroundPortsContext = createContext(null);
|
|
4
|
-
export function usePlaygroundPorts(_required = {}, _optional = {}) {
|
|
5
|
-
|
|
4
|
+
export function usePlaygroundPorts(_required = {}, _optional = {}) {
|
|
5
|
+
const ports = useContext(PlaygroundPortsContext);
|
|
6
|
+
if (!ports)
|
|
7
|
+
throw new AdminConfigError({ issues: ['React scope unavailable: playground'] });
|
|
8
|
+
return ports;
|
|
9
|
+
}
|
|
6
10
|
//# sourceMappingURL=PlaygroundPorts.hooks.js.map
|
|
@@ -29,11 +29,8 @@ const DEFAULT_PENDING_LABEL = '…';
|
|
|
29
29
|
*/
|
|
30
30
|
export function ConfirmButton({ useConfirmButton: useConfirmButtonState = useConfirmButton, ...props }) {
|
|
31
31
|
const { confirming, buttonRef, handleClick, handleBlur } = useConfirmButtonState({ onConfirm: props.onConfirm, document }, { pending: props.pending, disabled: props.disabled });
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
: confirming
|
|
35
|
-
? props.confirmLabel
|
|
36
|
-
: props.label;
|
|
32
|
+
const idleLabel = confirming ? props.confirmLabel : props.label;
|
|
33
|
+
const label = props.pending ? (props.pendingLabel ?? DEFAULT_PENDING_LABEL) : idleLabel;
|
|
37
34
|
const className = [props.className, props.destructive ? 'btn-danger' : null].filter(Boolean).join(' ') || undefined;
|
|
38
35
|
return (_jsxs(_Fragment, { children: [_jsx("button", { ref: buttonRef, type: "button", className: className, "data-armed": confirming ? 'true' : undefined, disabled: props.disabled || props.pending, onClick: handleClick, onBlur: handleBlur, "aria-label": props.ariaLabel, children: label }), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: confirming ? `Press "${props.confirmLabel}" to confirm, or press Escape to cancel.` : '' })] }));
|
|
39
36
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { agentHandle } from '@jini-ai/agentic';
|
|
2
|
+
import { omitKeys } from '../../../core/omit-keys.js';
|
|
2
3
|
/**
|
|
3
4
|
* The agent-attribute port `ConfirmDialog` hands UI-kit's confirm controller. UI-kit names selects
|
|
4
5
|
* separately; agentic publishes them as fields and still owns handle validation. Absent role/label
|
|
@@ -18,7 +19,6 @@ export const confirmDialogAgentAttrs = (required, optional = {}) => agentHandle(
|
|
|
18
19
|
* @complexity O(k) in the number of attributes.
|
|
19
20
|
*/
|
|
20
21
|
export function withoutAccessibleName({ attrs }, _optional = {}) {
|
|
21
|
-
|
|
22
|
-
return rest;
|
|
22
|
+
return omitKeys(attrs ?? {}, ['aria-label']);
|
|
23
23
|
}
|
|
24
24
|
//# sourceMappingURL=confirm-dialog-attrs.js.map
|
|
@@ -154,7 +154,9 @@ function parseEmbedConfig(raw) {
|
|
|
154
154
|
}
|
|
155
155
|
function galleryIdentityLabel(config) {
|
|
156
156
|
const count = Array.isArray(config.images) ? config.images.length : 0;
|
|
157
|
-
|
|
157
|
+
if (count === 0)
|
|
158
|
+
return 'no images set';
|
|
159
|
+
return count === 1 ? '1 image' : `${count} images`;
|
|
158
160
|
}
|
|
159
161
|
export function InteractiveHtmlEditor(props) {
|
|
160
162
|
return (_jsx(HtmlEditor, { ...props, isProtectedElement: (el) => isProtectedEmbedElement({ el }), describeEmbedPlaceholder: (el) => describeEmbedPlaceholder({ el }) }));
|
|
@@ -121,14 +121,10 @@ export function useRowMenu({ itemCount, window, document }) {
|
|
|
121
121
|
openAt(0);
|
|
122
122
|
}
|
|
123
123
|
function onTriggerKeyDown(e) {
|
|
124
|
-
if (e.key
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
else if (e.key === 'ArrowUp') {
|
|
129
|
-
e.preventDefault();
|
|
130
|
-
openAt(itemCount - 1);
|
|
131
|
-
}
|
|
124
|
+
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')
|
|
125
|
+
return;
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
openAt(e.key === 'ArrowDown' ? 0 : itemCount - 1);
|
|
132
128
|
}
|
|
133
129
|
function onMenuKeyDown(e) {
|
|
134
130
|
switch (e.key) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { createContext, createElement, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
4
|
import { DEFAULT_ADMIN_BASE, adminHref } from '../../core/routing/rules.js';
|
|
5
5
|
import { useNavSections } from '../hooks/use-nav-sections.js';
|
|
@@ -43,6 +43,7 @@ export function EntityField(props) {
|
|
|
43
43
|
case 'integer':
|
|
44
44
|
case 'real': return _jsx(NumberInput, { ...props });
|
|
45
45
|
case 'text': return _jsx("input", { ...commonProps(props), value: typeof value === 'string' ? value : '', onChange: (e) => onChange({ value: blankToUndefined(e.target.value) }) });
|
|
46
|
+
default: return null;
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
//# sourceMappingURL=EntityField.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { trimCharsEnd } from '@jini-ai/core/text';
|
|
2
3
|
import { EntityIndex } from './EntityIndex.js';
|
|
3
4
|
import { EntityList } from './EntityList.js';
|
|
4
5
|
import { EntityDetail } from './EntityDetail.js';
|
|
@@ -18,7 +19,7 @@ export function createEntityRoutes({ adminBase, panelId, navigate }) {
|
|
|
18
19
|
throw new Error('Entity panelId must be one URL segment');
|
|
19
20
|
if (!adminBase.startsWith('/') || adminBase.startsWith('//') || /[?#]/.test(adminBase))
|
|
20
21
|
throw new Error('Entity adminBase must be an absolute pathname');
|
|
21
|
-
const base =
|
|
22
|
+
const base = trimCharsEnd({ text: adminBase, chars: '/' });
|
|
22
23
|
function routePath({ entity, id, view }) {
|
|
23
24
|
const root = `/${panelId}`;
|
|
24
25
|
if (entity === undefined)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Stable source templates let host dictionaries translate copy before values are inserted. */
|
|
2
2
|
export function entityText({ translate, key, values }) {
|
|
3
|
-
return translate({ key: key }).replace(/\{(
|
|
3
|
+
return translate({ key: key }).replace(/\{(\w+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
|
|
4
4
|
}
|
|
5
5
|
/**
|
|
6
6
|
* Adapts the host's object-arg {@link EntityTranslate} to the positional `(key) => string` form the
|
package/dist/react/overlays.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createControllerStore } from '../core/module/controller-store.js';
|
|
|
4
4
|
export function createOverlayController(_required = {}, _optional = {}) {
|
|
5
5
|
const store = createControllerStore({ initial: { entries: [] } });
|
|
6
6
|
let sequence = 0, cancelActive = null;
|
|
7
|
+
const nextSequence = () => { sequence += 1; return sequence; };
|
|
7
8
|
return {
|
|
8
9
|
getSnapshot: store.getSnapshot,
|
|
9
10
|
subscribe: store.subscribe,
|
|
@@ -28,7 +29,7 @@ export function createOverlayController(_required = {}, _optional = {}) {
|
|
|
28
29
|
signal?.addEventListener('abort', cancel, { once: true });
|
|
29
30
|
store.set({
|
|
30
31
|
patch: {
|
|
31
|
-
entries: [{ id:
|
|
32
|
+
entries: [{ id: nextSequence(), render: () => render({ resolve: finish, cancel }) }],
|
|
32
33
|
},
|
|
33
34
|
});
|
|
34
35
|
});
|