@cosmicdrift/kumiko-renderer 0.355.0 → 0.357.0
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/app/kumiko-screen.js +31 -49
- package/dist/app/row-actions.d.ts +8 -0
- package/dist/app/row-actions.js +30 -8
- package/dist/app/secret-mint-body.js +8 -4
- package/dist/components/availability-options-select.d.ts +8 -0
- package/dist/components/availability-options-select.js +16 -0
- package/dist/components/related-list-section.d.ts +4 -1
- package/dist/components/related-list-section.js +9 -2
- package/dist/components/render-edit-types.d.ts +14 -0
- package/dist/components/render-edit.js +23 -6
- package/dist/components/render-field.js +6 -10
- package/dist/components/use-action-menu-items.d.ts +10 -0
- package/dist/components/use-action-menu-items.js +47 -0
- package/dist/components/write-form-section.d.ts +5 -6
- package/dist/components/write-form-section.js +8 -6
- package/dist/i18n-defaults.js +2 -0
- package/dist/primitives.d.ts +2 -0
- package/package.json +5 -5
- package/src/changes.json +34 -0
|
@@ -8,8 +8,9 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
|
|
|
8
8
|
import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
|
|
9
9
|
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
|
|
10
10
|
import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
|
|
11
|
-
import { needsActionConfirm, RenderEditActionButton,
|
|
11
|
+
import { needsActionConfirm, RenderEditActionButton, } from "../components/render-edit-action-button.js";
|
|
12
12
|
import { RenderList } from "../components/render-list.js";
|
|
13
|
+
import { useActionMenuItems } from "../components/use-action-menu-items.js";
|
|
13
14
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
14
15
|
import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
|
|
15
16
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
@@ -798,6 +799,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
798
799
|
version: recordVersion,
|
|
799
800
|
[UPDATE_CHANGES_KEY]: snap.changes,
|
|
800
801
|
}), [entityId, recordVersion]);
|
|
802
|
+
const [formDirty, setFormDirty] = useState(false);
|
|
801
803
|
const nav = useNav();
|
|
802
804
|
const appFeatures = useAppFeatures();
|
|
803
805
|
const dispatcher = useDispatcher();
|
|
@@ -865,7 +867,14 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
865
867
|
onRecordLeft: handleRecordLeft,
|
|
866
868
|
sameEntityScreenId,
|
|
867
869
|
defaultWritePayloadId: entityId,
|
|
870
|
+
...(formDirty && {
|
|
871
|
+
discardChanges: {
|
|
872
|
+
confirm: effectiveTranslate("kumiko.form.unsavedChangesActionConfirm"),
|
|
873
|
+
confirmLabel: effectiveTranslate("kumiko.form.unsavedChangesActionConfirmLabel"),
|
|
874
|
+
},
|
|
875
|
+
}),
|
|
868
876
|
}), [
|
|
877
|
+
formDirty,
|
|
869
878
|
record,
|
|
870
879
|
effectiveTranslate,
|
|
871
880
|
nav,
|
|
@@ -877,7 +886,8 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
877
886
|
sameEntityScreenId,
|
|
878
887
|
entityId,
|
|
879
888
|
]);
|
|
880
|
-
const headerActions = useMemo(() => buildSectionActions(screen.actions ?? []), [buildSectionActions, screen.actions]);
|
|
889
|
+
const headerActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement !== "menu")), [buildSectionActions, screen.actions]);
|
|
890
|
+
const headerMenuActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement === "menu")), [buildSectionActions, screen.actions]);
|
|
881
891
|
const handleSubmitted = useCallback((result) => {
|
|
882
892
|
if (!result.isSuccess)
|
|
883
893
|
return;
|
|
@@ -926,7 +936,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
926
936
|
entityId: entityId,
|
|
927
937
|
// Stored customFields for the extension section, so editing shows the
|
|
928
938
|
// persisted values instead of behaving write-only.
|
|
929
|
-
extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, ...(screen.allowDelete !== false && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
|
|
939
|
+
extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, onDirtyChange: setFormDirty, ...(screen.allowDelete !== false && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), ...(headerMenuActions !== undefined && { menuActions: headerMenuActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
|
|
930
940
|
closeDrawer();
|
|
931
941
|
void onReload();
|
|
932
942
|
} })] }));
|
|
@@ -1603,7 +1613,14 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
|
|
|
1603
1613
|
// carry a value — a stale/hand-crafted URL (?…q=x on a non-searchable
|
|
1604
1614
|
// screen) must not smuggle a param the query's Zod schema doesn't accept.
|
|
1605
1615
|
const activeSearch = searchable ? urlState.q : "";
|
|
1606
|
-
|
|
1616
|
+
// Same predicate that makes a header clickable: a stale link onto a
|
|
1617
|
+
// sortable:false/unknown column must not reach the server (it answers 400).
|
|
1618
|
+
const urlSort = urlState.sort;
|
|
1619
|
+
const urlSortField = urlSort === null ? undefined : entity.fields[urlSort.field];
|
|
1620
|
+
const urlSortAllowed = urlSortField !== undefined && "sortable" in urlSortField && urlSortField.sortable === true;
|
|
1621
|
+
const activeSort = sortable
|
|
1622
|
+
? ((urlSortAllowed ? urlSort : null) ?? screen.defaultSort ?? null)
|
|
1623
|
+
: null;
|
|
1607
1624
|
const limit = urlState.pageSize ?? screen.pageSize ?? 50;
|
|
1608
1625
|
// Pages-mode only (see header comment) — an author-set pagination:
|
|
1609
1626
|
// "infinite" on a paginated projectionList is silently a no-op today.
|
|
@@ -1894,47 +1911,10 @@ function primaryHeaderAction(actions) {
|
|
|
1894
1911
|
// no button of its own to carry one.
|
|
1895
1912
|
function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, PageHeader, }) {
|
|
1896
1913
|
const t = useTranslation();
|
|
1897
|
-
const
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
// same writeHandler twice.
|
|
1901
|
-
const [busyActionId, setBusyActionId] = useState(null);
|
|
1902
|
-
const trigger = async (action) => {
|
|
1903
|
-
onError(null);
|
|
1904
|
-
setBusyActionId(action.id);
|
|
1905
|
-
try {
|
|
1906
|
-
await action.onPress();
|
|
1907
|
-
}
|
|
1908
|
-
catch (e) {
|
|
1909
|
-
onError(e instanceof Error ? e.message : String(e));
|
|
1910
|
-
}
|
|
1911
|
-
finally {
|
|
1912
|
-
setBusyActionId(null);
|
|
1913
|
-
}
|
|
1914
|
-
};
|
|
1915
|
-
const toMenuItem = (action) => ({
|
|
1916
|
-
id: action.id,
|
|
1917
|
-
label: action.label,
|
|
1918
|
-
...(action.icon !== undefined && { icon: action.icon }),
|
|
1919
|
-
variant: action.style === "danger" ? "danger" : "default",
|
|
1920
|
-
...(busyActionId !== null && { disabled: true }),
|
|
1921
|
-
onSelect: () => {
|
|
1922
|
-
if (needsActionConfirm(action)) {
|
|
1923
|
-
setPendingAction(action);
|
|
1924
|
-
}
|
|
1925
|
-
else {
|
|
1926
|
-
void trigger(action);
|
|
1927
|
-
}
|
|
1928
|
-
},
|
|
1914
|
+
const { toMenuItem, confirmDialog: pendingActionDialog } = useActionMenuItems({
|
|
1915
|
+
Dialog,
|
|
1916
|
+
onError,
|
|
1929
1917
|
});
|
|
1930
|
-
const pendingActionDialog = pendingAction !== null && (_jsx(RenderEditActionConfirmDialog, { action: pendingAction, open: true, onOpenChange: (open) => {
|
|
1931
|
-
if (!open)
|
|
1932
|
-
setPendingAction(null);
|
|
1933
|
-
}, onConfirm: async () => {
|
|
1934
|
-
const action = pendingAction;
|
|
1935
|
-
setPendingAction(null);
|
|
1936
|
-
await trigger(action);
|
|
1937
|
-
}, Dialog: Dialog }));
|
|
1938
1918
|
if (compact && PageHeader !== undefined) {
|
|
1939
1919
|
const primary = primaryHeaderAction(actions);
|
|
1940
1920
|
// A bare icon for a destructive or confirm-gated action (e.g. "x" for terminate)
|
|
@@ -1994,9 +1974,11 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1994
1974
|
// A writeForm section's handler creates a new record (see EditWriteFormSection's
|
|
1995
1975
|
// doc) — both the outer record (header/metrics/other sections) and a sibling
|
|
1996
1976
|
// relatedList section's own independently-fetched rows must reflect it. A
|
|
1997
|
-
// query refetch alone only refreshes `record` below; bumping this
|
|
1998
|
-
// <RenderEdit>'s
|
|
1999
|
-
//
|
|
1977
|
+
// query refetch alone only refreshes `record` below; bumping this as
|
|
1978
|
+
// <RenderEdit>'s refreshToken makes RelatedListSection's own useQuery
|
|
1979
|
+
// re-run too (it has no `live` subscription of its own). Refetch instead of
|
|
1980
|
+
// remount (a `key`) so unsaved input in the other writeForm sections and
|
|
1981
|
+
// the related lists' local state survive.
|
|
2000
1982
|
const [reloadNonce, setReloadNonce] = useState(0);
|
|
2001
1983
|
const reloadDetail = useCallback(async () => {
|
|
2002
1984
|
await refetchAfterWrite(detailQuery.refetch);
|
|
@@ -2252,12 +2234,12 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
2252
2234
|
...(count !== undefined && { count }),
|
|
2253
2235
|
};
|
|
2254
2236
|
}), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
|
|
2255
|
-
return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: renderedDetailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
|
|
2237
|
+
return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { refreshToken: reloadNonce, screen: renderedDetailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
|
|
2256
2238
|
hasTabs ||
|
|
2257
2239
|
screen.slots?.header !== undefined ||
|
|
2258
2240
|
screen.recordTitleField !== undefined) && {
|
|
2259
2241
|
headerRegion: renderHeaderContent,
|
|
2260
|
-
}), buildSectionActions: buildSectionActions, valueDisplay: screen.valueDisplay ?? "text" },
|
|
2242
|
+
}), buildSectionActions: buildSectionActions, valueDisplay: screen.valueDisplay ?? "text" }, entityId), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
|
|
2261
2243
|
closeDrawer();
|
|
2262
2244
|
void reloadDetail();
|
|
2263
2245
|
} })] }));
|
|
@@ -53,6 +53,10 @@ export declare function buildProjectionToolbarActions(options: {
|
|
|
53
53
|
readonly record?: Readonly<Record<string, unknown>>;
|
|
54
54
|
readonly host: ReturnHost | undefined;
|
|
55
55
|
}): readonly ToolbarActionButton[] | undefined;
|
|
56
|
+
export type DiscardChanges = {
|
|
57
|
+
readonly confirm: string;
|
|
58
|
+
readonly confirmLabel: string;
|
|
59
|
+
};
|
|
56
60
|
export declare function deletesShownRecord(action: RowActionWriteHandler, payload: Readonly<Record<string, unknown>>, shownRecordId: unknown): boolean;
|
|
57
61
|
export type RecordLeftHandler = (action: RowActionWriteHandler, resultData: unknown, deletedShownRecord: boolean) => void;
|
|
58
62
|
export declare function buildRecordActions(options: {
|
|
@@ -85,5 +89,9 @@ export declare function buildRecordActions(options: {
|
|
|
85
89
|
* caller editing a fixed entity passes its route id so the payload does
|
|
86
90
|
* not depend on the loaded record carrying an `id` field. */
|
|
87
91
|
readonly defaultWritePayloadId?: string;
|
|
92
|
+
/** Translated hint added to the confirm of every writeHandler and drawer
|
|
93
|
+
* action (they reload or remount the surrounding form). Set while that
|
|
94
|
+
* form holds unsaved input so it is not lost silently. */
|
|
95
|
+
readonly discardChanges?: DiscardChanges;
|
|
88
96
|
}): readonly RenderEditAction[] | undefined;
|
|
89
97
|
export {};
|
package/dist/app/row-actions.js
CHANGED
|
@@ -341,14 +341,27 @@ function buildNavigateRecordAction(action, options) {
|
|
|
341
341
|
return buildNavigateScreenAction(action, action.screen, options);
|
|
342
342
|
return undefined;
|
|
343
343
|
}
|
|
344
|
+
// The action's own confirm text stays first; the discard hint follows after a
|
|
345
|
+
// blank line so both show in the single confirm dialog.
|
|
346
|
+
function withDiscardHint(ownConfirm, discardChanges) {
|
|
347
|
+
if (discardChanges === undefined)
|
|
348
|
+
return ownConfirm;
|
|
349
|
+
return ownConfirm === undefined
|
|
350
|
+
? discardChanges.confirm
|
|
351
|
+
: `${ownConfirm}\n\n${discardChanges.confirm}`;
|
|
352
|
+
}
|
|
344
353
|
function buildDrawerRecordAction(action, options) {
|
|
345
|
-
const { record, translate, actionIcon, openDrawer } = options;
|
|
354
|
+
const { record, translate, actionIcon, openDrawer, discardChanges } = options;
|
|
346
355
|
return {
|
|
347
356
|
id: action.id,
|
|
348
357
|
label: translate(action.label),
|
|
349
358
|
...(action.style !== undefined && { style: action.style }),
|
|
350
359
|
...(action.display !== undefined && { display: action.display }),
|
|
351
360
|
confirmRequired: false,
|
|
361
|
+
...(discardChanges !== undefined && {
|
|
362
|
+
confirm: discardChanges.confirm,
|
|
363
|
+
confirmLabel: discardChanges.confirmLabel,
|
|
364
|
+
}),
|
|
352
365
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
353
366
|
onPress: () => {
|
|
354
367
|
const initialValues = action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
|
|
@@ -365,18 +378,20 @@ export function deletesShownRecord(action, payload, shownRecordId) {
|
|
|
365
378
|
payload["id"] === shownRecordId);
|
|
366
379
|
}
|
|
367
380
|
function buildWriteHandlerRecordAction(action, options) {
|
|
368
|
-
const { record, translate, actionIcon, dispatcher, onWriteSuccess, defaultWritePayloadId, onRecordLeft, } = options;
|
|
381
|
+
const { record, translate, actionIcon, dispatcher, onWriteSuccess, defaultWritePayloadId, onRecordLeft, discardChanges, } = options;
|
|
369
382
|
const shownRecordId = defaultWritePayloadId ?? record["id"];
|
|
383
|
+
const confirm = withDiscardHint(action.confirm !== undefined ? translate(action.confirm) : undefined, discardChanges);
|
|
384
|
+
// The discard label wins: the action then also throws away the unsaved input.
|
|
385
|
+
const confirmLabel = discardChanges?.confirmLabel ??
|
|
386
|
+
(action.confirmLabel !== undefined ? translate(action.confirmLabel) : undefined);
|
|
370
387
|
return {
|
|
371
388
|
id: action.id,
|
|
372
389
|
label: translate(action.label),
|
|
373
390
|
...(action.style !== undefined && { style: action.style }),
|
|
374
391
|
...(action.display !== undefined && { display: action.display }),
|
|
375
392
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
376
|
-
...(
|
|
377
|
-
...(
|
|
378
|
-
confirmLabel: translate(action.confirmLabel),
|
|
379
|
-
}),
|
|
393
|
+
...(confirm !== undefined && { confirm }),
|
|
394
|
+
...(confirmLabel !== undefined && { confirmLabel }),
|
|
380
395
|
onPress: async () => {
|
|
381
396
|
const payload = action.payload !== undefined
|
|
382
397
|
? evalRowExtractor(action.payload, record)
|
|
@@ -402,7 +417,7 @@ function buildWriteHandlerRecordAction(action, options) {
|
|
|
402
417
|
// resolved against the record currently being viewed/edited, independent of
|
|
403
418
|
// any table row.
|
|
404
419
|
export function buildRecordActions(options) {
|
|
405
|
-
const { actions, record, translate, nav, host, dispatcher, openDrawer, onWriteSuccess, onRecordLeft, defaultScreenTargetEntityId, sameEntityScreenId, defaultWritePayloadId, } = options;
|
|
420
|
+
const { actions, record, translate, nav, host, dispatcher, openDrawer, onWriteSuccess, onRecordLeft, defaultScreenTargetEntityId, sameEntityScreenId, defaultWritePayloadId, discardChanges, } = options;
|
|
406
421
|
const out = [];
|
|
407
422
|
for (const action of actions) {
|
|
408
423
|
if (action.visible !== undefined && !evalFieldCondition(action.visible, record))
|
|
@@ -423,7 +438,13 @@ export function buildRecordActions(options) {
|
|
|
423
438
|
continue;
|
|
424
439
|
}
|
|
425
440
|
if (action.kind === "drawer") {
|
|
426
|
-
out.push(buildDrawerRecordAction(action, {
|
|
441
|
+
out.push(buildDrawerRecordAction(action, {
|
|
442
|
+
record,
|
|
443
|
+
translate,
|
|
444
|
+
actionIcon,
|
|
445
|
+
openDrawer,
|
|
446
|
+
discardChanges,
|
|
447
|
+
}));
|
|
427
448
|
continue;
|
|
428
449
|
}
|
|
429
450
|
// writeHandler — skip without a dispatcher instead of crashing.
|
|
@@ -437,6 +458,7 @@ export function buildRecordActions(options) {
|
|
|
437
458
|
onWriteSuccess,
|
|
438
459
|
defaultWritePayloadId,
|
|
439
460
|
onRecordLeft,
|
|
461
|
+
discardChanges,
|
|
440
462
|
}));
|
|
441
463
|
}
|
|
442
464
|
return out.length > 0 ? out : undefined;
|
|
@@ -47,7 +47,7 @@ function isBlank(value) {
|
|
|
47
47
|
export function SecretMintBody({ schema, screen, translate }) {
|
|
48
48
|
const nav = useNav();
|
|
49
49
|
const returnTarget = useReturnTarget(screen.id);
|
|
50
|
-
const { Card, Heading, Banner, Button, Text, Grid, GridCell, Section, SecretReveal } = usePrimitives();
|
|
50
|
+
const { Card, Heading, Banner, Button, Text, Grid, GridCell, Section, SecretReveal, Field, Input, } = usePrimitives();
|
|
51
51
|
const t = useTranslation();
|
|
52
52
|
const effectiveTranslate = translate ?? t;
|
|
53
53
|
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
@@ -75,6 +75,7 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
75
75
|
]);
|
|
76
76
|
const [revealed, setRevealed] = useState(null);
|
|
77
77
|
const [done, setDone] = useState(false);
|
|
78
|
+
const [acknowledged, setAcknowledged] = useState(false);
|
|
78
79
|
// The secret was minted server-side but none of the declared reveal fields
|
|
79
80
|
// came back; the mint form must not return (a re-mint would invalidate it).
|
|
80
81
|
const [revealMissing, setRevealMissing] = useState(false);
|
|
@@ -108,6 +109,7 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
108
109
|
setRevealMissing(true);
|
|
109
110
|
return;
|
|
110
111
|
}
|
|
112
|
+
setAcknowledged(false);
|
|
111
113
|
setRevealed(values);
|
|
112
114
|
carriedRef.current =
|
|
113
115
|
confirm?.carry !== undefined ? extractCarriedValues(result.data, confirm.carry) : {};
|
|
@@ -175,7 +177,9 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
175
177
|
},
|
|
176
178
|
];
|
|
177
179
|
});
|
|
178
|
-
const
|
|
180
|
+
const acknowledgeKey = screen.reveal.acknowledge;
|
|
181
|
+
const acknowledgeBlocked = acknowledgeKey !== undefined && !acknowledged;
|
|
182
|
+
const revealContent = (_jsxs(_Fragment, { children: [_jsx(Heading, { variant: "page", children: effectiveTranslate(screen.reveal.title ?? "kumiko.secretMint.title") }), _jsx(Banner, { variant: "warning", testId: "kumiko-screen-secret-mint-warning", children: effectiveTranslate(screen.reveal.warning ?? "kumiko.secretMint.warning") }), SecretReveal !== undefined ? (_jsx(SecretReveal, { values: values, copyLabel: effectiveTranslate("kumiko.secretMint.copy"), copiedLabel: effectiveTranslate("kumiko.secretMint.copied"), testId: "kumiko-screen-secret-mint-reveal" })) : (_jsx(Grid, { columns: 1, testId: "kumiko-screen-secret-mint-reveal", children: values.map((v) => (_jsxs(GridCell, { children: [_jsx(Text, { children: v.label }), _jsx(Text, { variant: "code", children: v.value })] }, v.label))) })), acknowledgeKey !== undefined && (_jsx(Field, { id: "secret-mint-acknowledge", label: effectiveTranslate(acknowledgeKey), layout: "inline", testId: "secret-mint-acknowledge", children: _jsx(Input, { kind: "boolean", id: "secret-mint-acknowledge", name: "secret-mint-acknowledge", value: acknowledged, onChange: setAcknowledged }) }))] }));
|
|
179
183
|
// One screen form like the mint phase: the reveal leads the form column
|
|
180
184
|
// instead of the confirm form nesting a second card inside a card. The
|
|
181
185
|
// keys stop the mint and confirm forms sharing one instance (and its
|
|
@@ -186,9 +190,9 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
186
190
|
if (confirmCurrencyLoading) {
|
|
187
191
|
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
188
192
|
}
|
|
189
|
-
return (_jsx(RenderEdit, { screen: confirmScreen, i18nScreenId: screen.id, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, onCancel: handleCancel ?? restartMint, fillScreenHeight: true, leadContent: _jsx(Section, { testId: "kumiko-screen-secret-mint-card", children: revealContent }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) }, "confirm"));
|
|
193
|
+
return (_jsx(RenderEdit, { screen: confirmScreen, i18nScreenId: screen.id, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, onCancel: handleCancel ?? restartMint, fillScreenHeight: true, submitBlocked: acknowledgeBlocked, leadContent: _jsx(Section, { testId: "kumiko-screen-secret-mint-card", children: revealContent }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) }, "confirm"));
|
|
190
194
|
}
|
|
191
|
-
return (_jsxs(Card, { options: { screenBody: true }, testId: "kumiko-screen-secret-mint-card", children: [revealContent, _jsx(Button, { type: "button", variant: "primary", onClick: finishMint, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") })] }));
|
|
195
|
+
return (_jsxs(Card, { options: { screenBody: true }, testId: "kumiko-screen-secret-mint-card", children: [revealContent, _jsx(Button, { type: "button", variant: "primary", onClick: finishMint, disabled: acknowledgeBlocked, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") })] }));
|
|
192
196
|
}
|
|
193
197
|
if (mintCurrencyLoading) {
|
|
194
198
|
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
@@ -9,6 +9,14 @@ export type SelectOptionEntry = {
|
|
|
9
9
|
};
|
|
10
10
|
export declare function staticSelectOptions(field: EditFieldViewModel): readonly SelectOptionEntry[];
|
|
11
11
|
export declare function mergeOptionAvailability(options: readonly SelectOptionEntry[], rows: readonly unknown[], current: string): readonly SelectOptionEntry[];
|
|
12
|
+
export declare function filterUnavailableOptions(options: readonly SelectOptionEntry[], rows: readonly unknown[], currentValues: readonly string[]): readonly SelectOptionEntry[];
|
|
13
|
+
type AvailabilityOptionsMultiSelectProps = {
|
|
14
|
+
readonly field: EditFieldViewModel;
|
|
15
|
+
readonly query: string;
|
|
16
|
+
readonly currentValues: readonly string[];
|
|
17
|
+
readonly children: (options: readonly SelectOptionEntry[]) => ReactNode;
|
|
18
|
+
};
|
|
19
|
+
export declare function AvailabilityOptionsMultiSelect({ field, query, currentValues, children, }: AvailabilityOptionsMultiSelectProps): ReactNode;
|
|
12
20
|
type AvailabilityOptionsSelectProps = {
|
|
13
21
|
readonly field: EditFieldViewModel;
|
|
14
22
|
readonly query: string;
|
|
@@ -36,6 +36,22 @@ export function mergeOptionAvailability(options, rows, current) {
|
|
|
36
36
|
};
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
|
+
// Combobox primitives cannot disable a single option, so unavailable ones are
|
|
40
|
+
// dropped; an already selected value stays listed so it can still be removed.
|
|
41
|
+
export function filterUnavailableOptions(options, rows, currentValues) {
|
|
42
|
+
const unavailable = new Set();
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
if (isAvailabilityRow(row) && row.disabled === true)
|
|
45
|
+
unavailable.add(row.value);
|
|
46
|
+
}
|
|
47
|
+
return options.filter((option) => !unavailable.has(option.value) || currentValues.includes(option.value));
|
|
48
|
+
}
|
|
49
|
+
// Until the result arrives, and when the query fails, every static option is
|
|
50
|
+
// shown; the write path enforces the rule either way.
|
|
51
|
+
export function AvailabilityOptionsMultiSelect({ field, query, currentValues, children, }) {
|
|
52
|
+
const { data } = useQuery(query, {});
|
|
53
|
+
return children(filterUnavailableOptions(staticSelectOptions(field), data?.rows ?? [], currentValues));
|
|
54
|
+
}
|
|
39
55
|
// Select with static options whose availability (SelectFieldDef.optionsAvailabilityQuery)
|
|
40
56
|
// comes from a query, so it has to be a mounted component for useQuery. Until the
|
|
41
57
|
// result arrives, and when the query fails, every option stays choosable; the write
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import type { EditRelatedListSectionViewModel, Translate } from "@cosmicdrift/kumiko-headless";
|
|
3
3
|
import { type ReactNode } from "react";
|
|
4
|
-
export declare function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, embedded, onAfterWrite, }: {
|
|
4
|
+
export declare function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, embedded, onAfterWrite, refreshToken, }: {
|
|
5
5
|
readonly section: EditRelatedListSectionViewModel;
|
|
6
6
|
readonly parentId: string;
|
|
7
7
|
/** The enclosing projectionDetail's own record (the "Akte") — evaluates
|
|
@@ -30,4 +30,7 @@ export declare function RelatedListSection({ section, parentId, record, featureN
|
|
|
30
30
|
* toolbar action, emptyState action) succeeded — lets a host reload data
|
|
31
31
|
* the write also changed. */
|
|
32
32
|
readonly onAfterWrite?: () => void | Promise<void>;
|
|
33
|
+
/** A change (not the mount) refetches the rows in place, keeping local
|
|
34
|
+
* sort/search/filter state — the host's record changed without a remount. */
|
|
35
|
+
readonly refreshToken?: number;
|
|
33
36
|
}): ReactNode;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { evalFieldCondition, normalizeListColumn, parseRefTarget, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
-
import { useCallback, useMemo, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useAppFeatures } from "../app/app-features-context.js";
|
|
5
5
|
import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
|
|
6
6
|
import { useNav } from "../app/nav.js";
|
|
@@ -82,7 +82,7 @@ function buildRowTone(rule) {
|
|
|
82
82
|
return undefined;
|
|
83
83
|
return (row) => (evalFieldCondition(rule, { ...row.values }) ? rule.tone : undefined);
|
|
84
84
|
}
|
|
85
|
-
export function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, embedded, onAfterWrite, }) {
|
|
85
|
+
export function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, embedded, onAfterWrite, refreshToken, }) {
|
|
86
86
|
const { Banner, Section, Card, FillContainer, Text, Button, Dialog } = usePrimitives();
|
|
87
87
|
const [emptyStateActionError, setEmptyStateActionError] = useState(null);
|
|
88
88
|
const t = useTranslation();
|
|
@@ -161,6 +161,13 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
161
161
|
]);
|
|
162
162
|
const rowsQuery = useQuery(section.query, payload);
|
|
163
163
|
const ownRefetch = rowsQuery.refetch;
|
|
164
|
+
const lastRefreshTokenRef = useRef(refreshToken);
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (lastRefreshTokenRef.current === refreshToken)
|
|
167
|
+
return;
|
|
168
|
+
lastRefreshTokenRef.current = refreshToken;
|
|
169
|
+
void ownRefetch();
|
|
170
|
+
}, [refreshToken, ownRefetch]);
|
|
164
171
|
const refetchSelfAndHost = useCallback(async () => {
|
|
165
172
|
await ownRefetch();
|
|
166
173
|
await onAfterWrite?.();
|
|
@@ -48,6 +48,11 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
48
48
|
readonly onDelete?: () => Promise<DispatcherError | void> | DispatcherError | void;
|
|
49
49
|
readonly onCancel?: () => void;
|
|
50
50
|
readonly onReload?: () => void;
|
|
51
|
+
/** Bumped by the host after it refetched `initial`. A change (not the mount)
|
|
52
|
+
* re-seeds a clean form with the new `initial` and makes embedded relatedList
|
|
53
|
+
* sections refetch, instead of the host remounting the whole form via `key`
|
|
54
|
+
* (which would drop unsaved input in sibling writeForm sections). */
|
|
55
|
+
readonly refreshToken?: number;
|
|
51
56
|
/** Fires when the form gains or loses unsaved input (field changes or a
|
|
52
57
|
* dirty extension section). Lets a host such as a drawer guard closing. */
|
|
53
58
|
readonly onDirtyChange?: (dirty: boolean) => void;
|
|
@@ -64,6 +69,11 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
64
69
|
* same split as `onCopyLink`) — RenderEdit only wires the button, its
|
|
65
70
|
* busy state and its confirm dialog. */
|
|
66
71
|
readonly actions?: readonly RenderEditAction[];
|
|
72
|
+
/** Record actions declared with `placement: "menu"`, resolved like `actions`.
|
|
73
|
+
* Rendered in the header "…" menu ahead of copy-link/delete; without a
|
|
74
|
+
* header menu (drawer, card form, shell without a header slot) they fall
|
|
75
|
+
* back to buttons next to `actions`. */
|
|
76
|
+
readonly menuActions?: readonly RenderEditAction[];
|
|
67
77
|
/** Opens a relatedList section row's drawer-kind action (fw#2710).
|
|
68
78
|
* RenderEdit has no `schema` to resolve the target actionForm itself —
|
|
69
79
|
* the caller (ProjectionDetailBody, which does have schema) supplies the
|
|
@@ -188,6 +198,10 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
188
198
|
* rhythm — for content that belongs to the form but is no field (e.g. the
|
|
189
199
|
* secret a confirm step refers to). */
|
|
190
200
|
readonly leadContent?: ReactNode;
|
|
201
|
+
/** Blocks submit (button and Enter) while fields stay editable — unlike
|
|
202
|
+
* `disabled`, which locks the whole form. For a gate the host owns, e.g.
|
|
203
|
+
* an acknowledgement checkbox in `leadContent`. */
|
|
204
|
+
readonly submitBlocked?: boolean;
|
|
191
205
|
/** Show the unsaved-changes footer (count, Discard, "Save changes") even
|
|
192
206
|
* without an entity id or a screen-height form — for hosts that edit an
|
|
193
207
|
* existing server-side record through `customSubmit` (configEdit, also when
|
|
@@ -3,6 +3,7 @@ import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION
|
|
|
3
3
|
import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
5
5
|
import { AllFieldsRequiredProvider } from "./render-field.js";
|
|
6
|
+
import { useActionMenuItems } from "./use-action-menu-items.js";
|
|
6
7
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
8
|
import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
|
|
8
9
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
|
|
@@ -223,7 +224,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
|
|
|
223
224
|
return translate(template, values);
|
|
224
225
|
}
|
|
225
226
|
export function RenderEdit(props) {
|
|
226
|
-
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, } = props;
|
|
227
|
+
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, } = props;
|
|
227
228
|
const i18nScreenId = props.i18nScreenId ?? screen.id;
|
|
228
229
|
const { customSubmit } = props;
|
|
229
230
|
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
@@ -300,6 +301,7 @@ export function RenderEdit(props) {
|
|
|
300
301
|
setHasExtensionRegistrations(hasRegistrations);
|
|
301
302
|
});
|
|
302
303
|
const { Button, Banner, Card, Dialog, Form, Section, Grid, GridCell, Text, Progress, StepBar, WizardStepGroup, Tabs, PageHeader, ActionOverflowMenu, } = usePrimitives();
|
|
304
|
+
const { toMenuItem: menuActionToItem, confirmDialog: menuActionConfirmDialog } = useActionMenuItems({ Dialog, onError: setActionError });
|
|
303
305
|
const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
|
|
304
306
|
const pageHeaderCompact = usePageHeaderCompact();
|
|
305
307
|
const insideDrawer = useInsideDrawer();
|
|
@@ -355,6 +357,17 @@ export function RenderEdit(props) {
|
|
|
355
357
|
...(ctx !== undefined && { ctx }),
|
|
356
358
|
...(submitConfig !== undefined && { submit: submitConfig }),
|
|
357
359
|
});
|
|
360
|
+
const lastRefreshTokenRef = useRef(refreshToken);
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
if (lastRefreshTokenRef.current === refreshToken)
|
|
363
|
+
return;
|
|
364
|
+
lastRefreshTokenRef.current = refreshToken;
|
|
365
|
+
// Never overwrite user input: a dirty form keeps its values.
|
|
366
|
+
if (controller.getSnapshot().isDirty)
|
|
367
|
+
return;
|
|
368
|
+
controller.setValues(initial);
|
|
369
|
+
controller.rebase();
|
|
370
|
+
}, [refreshToken, controller, initial]);
|
|
358
371
|
// Derived from the screen id + the host entity id only — never from
|
|
359
372
|
// `vm.id`, which lives in the form values a restore mutates (load under one
|
|
360
373
|
// key, save under another). Edit-mode: `${screen.id}:${entityIdProp}`,
|
|
@@ -900,7 +913,7 @@ export function RenderEdit(props) {
|
|
|
900
913
|
// Locked state (#1896): the submit button is visibly disabled, but a
|
|
901
914
|
// native form submit (Enter key) reaches this handler regardless of the
|
|
902
915
|
// button's disabled attribute — block it here too, not just in the UI.
|
|
903
|
-
if (disabled)
|
|
916
|
+
if (disabled || submitBlocked)
|
|
904
917
|
return;
|
|
905
918
|
// Sync guard — React state `isSubmitting` is too late for double-clicks
|
|
906
919
|
// in the same tick (customSubmit has no submitInFlight of its own).
|
|
@@ -1041,6 +1054,7 @@ export function RenderEdit(props) {
|
|
|
1041
1054
|
// action.
|
|
1042
1055
|
const hasSecondaryFormActions = (!menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined)) ||
|
|
1043
1056
|
(actions !== undefined && actions.length > 0) ||
|
|
1057
|
+
(!menuPageHeaderActions && menuActions !== undefined && menuActions.length > 0) ||
|
|
1044
1058
|
(onCancel !== undefined && !cancelInHeaderMenu);
|
|
1045
1059
|
// Teil-C collapse rule: driven by the custom `actions` group only — a
|
|
1046
1060
|
// resolved true also folds the Copy-Link button (same visual group in the
|
|
@@ -1048,6 +1062,7 @@ export function RenderEdit(props) {
|
|
|
1048
1062
|
const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
|
|
1049
1063
|
const headerMenuItems = menuPageHeaderActions
|
|
1050
1064
|
? [
|
|
1065
|
+
...(menuActions ?? []).map(menuActionToItem),
|
|
1051
1066
|
...(onCopyLink !== undefined
|
|
1052
1067
|
? [
|
|
1053
1068
|
{
|
|
@@ -1091,7 +1106,8 @@ export function RenderEdit(props) {
|
|
|
1091
1106
|
setLinkCopied(true);
|
|
1092
1107
|
}, children: iconOnlyMidActions
|
|
1093
1108
|
? null
|
|
1094
|
-
: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink") })), actions?.map((action) => (_jsx(RenderEditActionButton, { action: action, iconOnly: iconOnlyMidActions, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))),
|
|
1109
|
+
: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink") })), actions?.map((action) => (_jsx(RenderEditActionButton, { action: action, iconOnly: iconOnlyMidActions, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))), !menuPageHeaderActions &&
|
|
1110
|
+
menuActions?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))), onCancel !== undefined && !cancelInHeaderMenu && (_jsx(Button, { type: "button", variant: "secondary", onClick: () => onCancel(), testId: "render-edit-cancel", children: translate("kumiko.actions.cancel") }))] }));
|
|
1095
1111
|
const headerSlot = screen.slots?.header;
|
|
1096
1112
|
const headerSlotMount = headerSlot !== undefined ? (_jsx(EditSlotMount, { slot: headerSlot, slotName: "header", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values })) : undefined;
|
|
1097
1113
|
// A function headerRegion places the slot itself (projectionDetail puts it
|
|
@@ -1156,7 +1172,8 @@ export function RenderEdit(props) {
|
|
|
1156
1172
|
!insideDrawer &&
|
|
1157
1173
|
submitWithoutChanges !== true) ||
|
|
1158
1174
|
isSubmitting ||
|
|
1159
|
-
disabled
|
|
1175
|
+
disabled ||
|
|
1176
|
+
submitBlocked, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
|
|
1160
1177
|
(isWizard
|
|
1161
1178
|
? "kumiko.actions.finish"
|
|
1162
1179
|
: hasDirtyFooter
|
|
@@ -1227,7 +1244,7 @@ export function RenderEdit(props) {
|
|
|
1227
1244
|
})
|
|
1228
1245
|
: translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
|
|
1229
1246
|
})();
|
|
1230
|
-
return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
|
|
1247
|
+
return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [menuActionConfirmDialog, titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
|
|
1231
1248
|
pageHeaderCompact && { overflowItems: headerMenuItems }), ...(headerMenuItems.length > 0 &&
|
|
1232
1249
|
!pageHeaderCompact &&
|
|
1233
1250
|
ActionOverflowMenu !== undefined && {
|
|
@@ -1337,7 +1354,7 @@ export function RenderEdit(props) {
|
|
|
1337
1354
|
return null;
|
|
1338
1355
|
const mount = (_jsx(RelatedListSection, { section: section, parentId: parentId,
|
|
1339
1356
|
// @cast-boundary form-values: TValues ist strukturell ein Record.
|
|
1340
|
-
record: snapshot.values, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, ...(fillScreenHeight === true && { grow: true }), ...(hideSectionTitles !== true &&
|
|
1357
|
+
record: snapshot.values, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, ...(refreshToken !== undefined && { refreshToken }), ...(fillScreenHeight === true && { grow: true }), ...(hideSectionTitles !== true &&
|
|
1341
1358
|
sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(onRelatedListDrawerAction !== undefined && {
|
|
1342
1359
|
onOpenDrawer: onRelatedListDrawerAction,
|
|
1343
1360
|
}) }, section.title));
|
|
@@ -13,7 +13,7 @@ import { useQuery } from "../hooks/use-query.js";
|
|
|
13
13
|
import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
|
|
14
14
|
import { useLocale, useTranslation } from "../i18n.js";
|
|
15
15
|
import { usePrimitives } from "../primitives.js";
|
|
16
|
-
import { AvailabilityOptionsSelect, staticSelectOptions } from "./availability-options-select.js";
|
|
16
|
+
import { AvailabilityOptionsMultiSelect, AvailabilityOptionsSelect, staticSelectOptions, } from "./availability-options-select.js";
|
|
17
17
|
import { EmbeddedListField } from "./embedded-list-field.js";
|
|
18
18
|
import { MultiSelectCheckboxes } from "./multi-select-checkboxes.js";
|
|
19
19
|
import { QueryOptionsSelect } from "./query-options-select.js";
|
|
@@ -348,16 +348,12 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
|
|
|
348
348
|
case "tz":
|
|
349
349
|
return (_jsx(Input, { kind: "tz", ...common, value: stringValue(field.value), onChange: (v) => onChange(v) }));
|
|
350
350
|
case "multiSelect": {
|
|
351
|
-
const rawOptions = field.options ?? [];
|
|
352
|
-
const labels = field.optionLabels;
|
|
353
|
-
const multiSelectOptions = labels !== undefined
|
|
354
|
-
? rawOptions.map((value) => ({ value, label: labels[value] ?? value }))
|
|
355
|
-
: rawOptions.map((value) => ({ value, label: value }));
|
|
356
351
|
const arrayValue = Array.isArray(field.value) ? field.value : [];
|
|
357
|
-
|
|
358
|
-
|
|
352
|
+
const renderMultiSelect = (options) => field.display === "checkboxes" ? (_jsx(MultiSelectCheckboxes, { field: field, id: id, options: options, value: arrayValue, onChange: (v) => onChange(v) })) : (_jsx(Input, { kind: "combobox", ...common, multiple: true, value: arrayValue, onChange: (v) => onChange(v), options: options }));
|
|
353
|
+
if (field.selectOptionsAvailabilityQuery !== undefined && field.readOnly !== true) {
|
|
354
|
+
return (_jsx(AvailabilityOptionsMultiSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, currentValues: arrayValue, children: renderMultiSelect }));
|
|
359
355
|
}
|
|
360
|
-
return (
|
|
356
|
+
return renderMultiSelect(staticSelectOptions(field));
|
|
361
357
|
}
|
|
362
358
|
case "money": {
|
|
363
359
|
const currency = resolveMoneyCurrency(field.value, field.currency);
|
|
@@ -421,7 +417,7 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
|
|
|
421
417
|
return (_jsx(Input, { kind: "textarea", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), ...(rows !== undefined && { rows }) }));
|
|
422
418
|
}
|
|
423
419
|
const icon = resolveFieldIcon(field);
|
|
424
|
-
return (_jsx(Input, { kind: "text", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), ...(icon !== undefined && { icon }) }));
|
|
420
|
+
return (_jsx(Input, { kind: "text", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), ...(icon !== undefined && { icon }), ...(field.autoComplete !== undefined && { autoComplete: field.autoComplete }) }));
|
|
425
421
|
}
|
|
426
422
|
}
|
|
427
423
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { ActionMenuItemSpec, usePrimitives } from "../primitives.js";
|
|
3
|
+
import type { RenderEditAction } from "./render-edit-types.js";
|
|
4
|
+
export declare function useActionMenuItems({ Dialog, onError, }: {
|
|
5
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
6
|
+
readonly onError: (text: string | null) => void;
|
|
7
|
+
}): {
|
|
8
|
+
readonly toMenuItem: (action: RenderEditAction) => ActionMenuItemSpec;
|
|
9
|
+
readonly confirmDialog: ReactNode;
|
|
10
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { needsActionConfirm, RenderEditActionConfirmDialog } from "./render-edit-action-button.js";
|
|
4
|
+
// A menu item has no button of its own to carry RenderEditActionButton's busy
|
|
5
|
+
// and confirm state, so this hook owns both for every item it creates.
|
|
6
|
+
export function useActionMenuItems({ Dialog, onError, }) {
|
|
7
|
+
const [pendingAction, setPendingAction] = useState(null);
|
|
8
|
+
// Without it the menu can be reopened mid-write and fire the same writeHandler twice.
|
|
9
|
+
const [busyActionId, setBusyActionId] = useState(null);
|
|
10
|
+
const trigger = async (action) => {
|
|
11
|
+
onError(null);
|
|
12
|
+
setBusyActionId(action.id);
|
|
13
|
+
try {
|
|
14
|
+
await action.onPress();
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
onError(e instanceof Error ? e.message : String(e));
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
setBusyActionId(null);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const toMenuItem = (action) => ({
|
|
24
|
+
id: action.id,
|
|
25
|
+
label: action.label,
|
|
26
|
+
...(action.icon !== undefined && { icon: action.icon }),
|
|
27
|
+
variant: action.style === "danger" ? "danger" : "default",
|
|
28
|
+
...(busyActionId !== null && { disabled: true }),
|
|
29
|
+
onSelect: () => {
|
|
30
|
+
if (needsActionConfirm(action)) {
|
|
31
|
+
setPendingAction(action);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
void trigger(action);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const confirmDialog = pendingAction !== null && (_jsx(RenderEditActionConfirmDialog, { action: pendingAction, open: true, onOpenChange: (open) => {
|
|
39
|
+
if (!open)
|
|
40
|
+
setPendingAction(null);
|
|
41
|
+
}, onConfirm: async () => {
|
|
42
|
+
const action = pendingAction;
|
|
43
|
+
setPendingAction(null);
|
|
44
|
+
await trigger(action);
|
|
45
|
+
}, Dialog: Dialog }));
|
|
46
|
+
return { toMenuItem, confirmDialog };
|
|
47
|
+
}
|
|
@@ -5,17 +5,16 @@ export type WriteFormSectionProps = {
|
|
|
5
5
|
readonly featureName: string;
|
|
6
6
|
readonly translate?: Translate;
|
|
7
7
|
readonly hideTitle?: boolean;
|
|
8
|
-
/** Fired after a successful submit
|
|
9
|
-
*
|
|
10
|
-
* ProjectionDetailBody's reloadNonce
|
|
8
|
+
/** Fired after a successful submit, once this section reset itself to its
|
|
9
|
+
* initial values — projectionDetail refetches its record (a new one now
|
|
10
|
+
* exists), see ProjectionDetailBody's reloadNonce in kumiko-screen.tsx. */
|
|
11
11
|
readonly onSubmitted: () => void;
|
|
12
12
|
/** section.actions, already resolved into buttons by the caller —
|
|
13
|
-
* rendered
|
|
14
|
-
* title-row actions slot. */
|
|
13
|
+
* rendered in the title row, apart from the section's own submit button. */
|
|
15
14
|
readonly actions?: ReactNode;
|
|
16
15
|
/** Set when this section is a whole tab: the submit button is handed to
|
|
17
16
|
* the host form's footer (pinned like every other screen's save) instead
|
|
18
|
-
* of the section
|
|
17
|
+
* of the end of the section body. Called with undefined on unmount. */
|
|
19
18
|
readonly onFooterAction?: (action: ReactNode | undefined) => void;
|
|
20
19
|
};
|
|
21
20
|
export declare function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, onFooterAction, }: WriteFormSectionProps): ReactNode;
|
|
@@ -55,6 +55,11 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
55
55
|
const result = await controller.submit();
|
|
56
56
|
if (result.isSuccess) {
|
|
57
57
|
setError(null);
|
|
58
|
+
// Edits typed while the write was in flight stay; only a clean form is reset.
|
|
59
|
+
if (!controller.getSnapshot().isDirty) {
|
|
60
|
+
controller.setValues(initial);
|
|
61
|
+
controller.rebase();
|
|
62
|
+
}
|
|
58
63
|
onSubmitted();
|
|
59
64
|
return;
|
|
60
65
|
}
|
|
@@ -73,7 +78,6 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
73
78
|
setIsSubmitting(false);
|
|
74
79
|
}
|
|
75
80
|
}
|
|
76
|
-
const content = (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: section.columns, children: section.fields.map((field) => (_jsx(GridCellForField, { field: field, columns: section.columns, issues: snapshot.errors[field.field], onChange: (v) => controller.setField(field.field, v), GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values }, field.field))) }), error !== null && (_jsx(Banner, { variant: "error", testId: "write-form-section-error", children: error }))] }));
|
|
77
81
|
// type="button" (not "submit") is load-bearing: this section is deliberately
|
|
78
82
|
// NOT a nested <form> (see the component doc above), so a "submit" type
|
|
79
83
|
// would instead trigger the host RenderEdit's own form submit.
|
|
@@ -90,17 +94,15 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
90
94
|
onFooterAction(submitButton);
|
|
91
95
|
return () => onFooterAction(undefined);
|
|
92
96
|
}, [onFooterAction, isSubmitting, submitLabel]);
|
|
93
|
-
const
|
|
97
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: section.columns, children: section.fields.map((field) => (_jsx(GridCellForField, { field: { ...field, value: snapshot.values[field.field] }, columns: section.columns, issues: snapshot.errors[field.field], onChange: (v) => controller.setField(field.field, v), GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values }, field.field))) }), error !== null && (_jsx(Banner, { variant: "error", testId: "write-form-section-error", children: error })), onFooterAction === undefined && (_jsx(Card, { options: { framed: false, footerBordered: false, footerInset: false }, slots: { footer: submitButton }, testId: "write-form-section-submit-row" }))] }));
|
|
94
98
|
// Section always flattens to a borderless divider when rendered inside
|
|
95
99
|
// RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
|
|
96
100
|
// stands in, like render-edit.tsx's tabs-mode fields/extension branches.
|
|
97
101
|
if (hideTitle) {
|
|
98
102
|
return (_jsx(Card, { options: { framed: false }, slots: {
|
|
99
103
|
...(section.description !== undefined && { subtitle: section.description }),
|
|
100
|
-
headerActions:
|
|
104
|
+
...(actions !== undefined && { headerActions: actions }),
|
|
101
105
|
}, testId: `write-form-${section.title ?? section.handler}`, children: content }));
|
|
102
106
|
}
|
|
103
|
-
|
|
104
|
-
// lifted into the footer instead when onFooterAction is set).
|
|
105
|
-
return (_jsx(Section, { ...(section.title !== undefined && { title: section.title }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), actions: titleRowActions, testId: `write-form-${section.title ?? section.handler}`, children: content }));
|
|
107
|
+
return (_jsx(Section, { ...(section.title !== undefined && { title: section.title }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), ...(actions !== undefined && { actions }), testId: `write-form-${section.title ?? section.handler}`, children: content }));
|
|
106
108
|
}
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -95,6 +95,8 @@ export const kumikoDefaultTranslations = {
|
|
|
95
95
|
"kumiko.form.saveChanges": "Save changes",
|
|
96
96
|
"kumiko.form.unsaved.one": "1 unsaved change",
|
|
97
97
|
"kumiko.form.unsaved.other": "{count} unsaved changes",
|
|
98
|
+
"kumiko.form.unsavedChangesActionConfirm": "You have unsaved changes in this form. They will be lost if you continue.",
|
|
99
|
+
"kumiko.form.unsavedChangesActionConfirmLabel": "Discard changes and continue",
|
|
98
100
|
"kumiko.wizard.next-with-title": "Next: {title}",
|
|
99
101
|
"kumiko.wizard.save-and-close": "Save and close",
|
|
100
102
|
"kumiko.pager.previousPage": "Previous page",
|
package/dist/primitives.d.ts
CHANGED
|
@@ -1157,6 +1157,8 @@ export type CardOptions = {
|
|
|
1157
1157
|
readonly radius?: "lg" | "xl";
|
|
1158
1158
|
/** Footer abgehoben mit border-t + bg-muted/30. Default true. */
|
|
1159
1159
|
readonly footerBordered?: boolean;
|
|
1160
|
+
/** Footer horizontal padding. false = none, so footer content aligns with the card edge. Default true. */
|
|
1161
|
+
readonly footerInset?: boolean;
|
|
1160
1162
|
/** Joins the ancestor fillHeight flex chain (fw#2722/#2778 — a tabs-mode
|
|
1161
1163
|
* relatedList table sizing to the panel instead of the page) instead of
|
|
1162
1164
|
* normal document-flow height. Default false. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.357.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.357.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.357.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.357.0",
|
|
21
21
|
"react": "^19.2.6",
|
|
22
22
|
"temporal-polyfill": "^0.3.2",
|
|
23
23
|
"zod": "^4.4.3"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"jsdom": "^29.1.1",
|
|
30
30
|
"react-dom": "^19.2.6",
|
|
31
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.357.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,38 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "MultiSelect inputs load optionsAvailabilityQuery and hide unavailable options"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"version": "0.357.0",
|
|
9
|
+
"type": "improvement",
|
|
10
|
+
"title": "HeaderActionGroup replaces the overflow menu child selector for header action wrappers",
|
|
11
|
+
"detail": "App wrappers around headerActions are no longer restyled into a stacked column inside the phone overflow menu (the [&>div] child selector reached into app markup). Use HeaderActionGroup instead: a horizontal row in the header, a stacked column in the overflow menu."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"version": "0.357.0",
|
|
15
|
+
"type": "breaking",
|
|
16
|
+
"title": "List card meta values wrap instead of being clipped; more than 3 meta columns fail boot",
|
|
17
|
+
"migration": "Narrow list cards (entityList, projectionList, relatedList sections, expandableRow) now render meta values as a dot-separated row that wraps onto further lines instead of being cut off after two lines (and no longer subject to a silent runtime cap of 3 values); only a single value wider than the card is truncated. Meta columns are all columns except the title (first column), the status select and columns with hideOnNarrow: true. validateBoot now rejects a list with more than 3 meta columns and names feature, screen, section and fields. Fix: mark the extra columns hideOnNarrow: true (tables keep them) or drop them. collectCardMetaOverflow(features) from @cosmicdrift/kumiko-framework/engine lists all offenders at once. headless now also honors hideOnNarrow on reference, derived, row-meta and renderer-only columns, which it previously ignored there."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"version": "0.357.0",
|
|
21
|
+
"type": "fix",
|
|
22
|
+
"title": "projectionList ignores a URL sort param on columns that are not sortable",
|
|
23
|
+
"detail": "A stale link like `?<screen>.sort=<col>` onto a `sortable: false` or unknown column is no longer sent to the server (which answers 400 since 0.355); the screen falls back to its defaultSort."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"version": "0.357.0",
|
|
27
|
+
"type": "breaking",
|
|
28
|
+
"title": "Section actions render in the title row instead of a footer; WriteFormSection submit moves below its fields",
|
|
29
|
+
"migration": "Section.actions (renderer-web DefaultSection) render top right in the title row since #3241, no longer as a footer. A button that depends on content below it (a confirmation checkbox, a list it acts on) belongs in the section's children instead of actions. WriteFormSection's own submit button now sits below its fields; caller actions stay in the title row."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"version": "0.356.0",
|
|
33
|
+
"type": "improvement",
|
|
34
|
+
"title": "entityEdit actions can go into the header \"...\" menu via placement: \"menu\""
|
|
35
|
+
},
|
|
2
36
|
{
|
|
3
37
|
"version": "0.354.0",
|
|
4
38
|
"type": "fix",
|