@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.
@@ -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, RenderEditActionConfirmDialog, } from "../components/render-edit-action-button.js";
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
- const activeSort = sortable ? (urlState.sort ?? screen.defaultSort ?? null) : null;
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 [pendingAction, setPendingAction] = useState(null);
1898
- // Menu items have no button of their own to carry RenderEditActionButton's
1899
- // busy state; without it the menu can be reopened mid-write and fire the
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 into
1998
- // <RenderEdit>'s `key` forces a full remount so RelatedListSection's own
1999
- // useQuery call re-runs too (it has no `live` subscription of its own).
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" }, `${entityId}:${reloadNonce}`), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
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 {};
@@ -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
- ...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
377
- ...(action.confirmLabel !== undefined && {
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, { record, translate, actionIcon, openDrawer }));
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 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))) }))] }));
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))), onCancel !== undefined && !cancelInHeaderMenu && (_jsx(Button, { type: "button", variant: "secondary", onClick: () => onCancel(), testId: "render-edit-cancel", children: translate("kumiko.actions.cancel") }))] }));
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, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
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
- if (field.display === "checkboxes") {
358
- return (_jsx(MultiSelectCheckboxes, { field: field, id: id, options: multiSelectOptions, value: arrayValue, onChange: (v) => onChange(v) }));
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 (_jsx(Input, { kind: "combobox", ...common, multiple: true, value: arrayValue, onChange: (v) => onChange(v), options: multiSelectOptions }));
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 — projectionDetail reloads its own
9
- * record (a new one now exists) via a full RenderEdit remount, see
10
- * ProjectionDetailBody's reloadNonce/key in kumiko-screen.tsx. */
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 alongside (before) the section's own submit button in the
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's title row. Called with undefined on unmount. */
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 titleRowActions = onFooterAction !== undefined ? (actions) : actions !== undefined ? (_jsxs(_Fragment, { children: [actions, submitButton] })) : (submitButton);
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: titleRowActions,
104
+ ...(actions !== undefined && { headerActions: actions }),
101
105
  }, testId: `write-form-${section.title ?? section.handler}`, children: content }));
102
106
  }
103
- // Submit plus caller actions share the title row's actions slot (submit is
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
  }
@@ -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",
@@ -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.355.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.355.0",
19
- "@cosmicdrift/kumiko-headless": "0.355.0",
20
- "@cosmicdrift/kumiko-types": "0.355.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.355.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",