@cosmicdrift/kumiko-renderer 0.356.0 → 0.358.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.
@@ -799,6 +799,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
799
799
  version: recordVersion,
800
800
  [UPDATE_CHANGES_KEY]: snap.changes,
801
801
  }), [entityId, recordVersion]);
802
+ const [formDirty, setFormDirty] = useState(false);
802
803
  const nav = useNav();
803
804
  const appFeatures = useAppFeatures();
804
805
  const dispatcher = useDispatcher();
@@ -866,7 +867,14 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
866
867
  onRecordLeft: handleRecordLeft,
867
868
  sameEntityScreenId,
868
869
  defaultWritePayloadId: entityId,
870
+ ...(formDirty && {
871
+ discardChanges: {
872
+ confirm: effectiveTranslate("kumiko.form.unsavedChangesActionConfirm"),
873
+ confirmLabel: effectiveTranslate("kumiko.form.unsavedChangesActionConfirmLabel"),
874
+ },
875
+ }),
869
876
  }), [
877
+ formDirty,
870
878
  record,
871
879
  effectiveTranslate,
872
880
  nav,
@@ -928,7 +936,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
928
936
  entityId: entityId,
929
937
  // Stored customFields for the extension section, so editing shows the
930
938
  // persisted values instead of behaving write-only.
931
- 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 }), ...(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: () => {
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: () => {
932
940
  closeDrawer();
933
941
  void onReload();
934
942
  } })] }));
@@ -1605,7 +1613,14 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1605
1613
  // carry a value — a stale/hand-crafted URL (?…q=x on a non-searchable
1606
1614
  // screen) must not smuggle a param the query's Zod schema doesn't accept.
1607
1615
  const activeSearch = searchable ? urlState.q : "";
1608
- 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;
1609
1624
  const limit = urlState.pageSize ?? screen.pageSize ?? 50;
1610
1625
  // Pages-mode only (see header comment) — an author-set pagination:
1611
1626
  // "infinite" on a paginated projectionList is silently a no-op today.
@@ -1959,9 +1974,11 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1959
1974
  // A writeForm section's handler creates a new record (see EditWriteFormSection's
1960
1975
  // doc) — both the outer record (header/metrics/other sections) and a sibling
1961
1976
  // relatedList section's own independently-fetched rows must reflect it. A
1962
- // query refetch alone only refreshes `record` below; bumping this into
1963
- // <RenderEdit>'s `key` forces a full remount so RelatedListSection's own
1964
- // 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.
1965
1982
  const [reloadNonce, setReloadNonce] = useState(0);
1966
1983
  const reloadDetail = useCallback(async () => {
1967
1984
  await refetchAfterWrite(detailQuery.refetch);
@@ -2217,12 +2234,12 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2217
2234
  ...(count !== undefined && { count }),
2218
2235
  };
2219
2236
  }), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
2220
- 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 ||
2221
2238
  hasTabs ||
2222
2239
  screen.slots?.header !== undefined ||
2223
2240
  screen.recordTitleField !== undefined) && {
2224
2241
  headerRegion: renderHeaderContent,
2225
- }), 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: () => {
2226
2243
  closeDrawer();
2227
2244
  void reloadDetail();
2228
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;
@@ -193,6 +198,10 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
193
198
  * rhythm — for content that belongs to the form but is no field (e.g. the
194
199
  * secret a confirm step refers to). */
195
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;
196
205
  /** Show the unsaved-changes footer (count, Discard, "Save changes") even
197
206
  * without an entity id or a screen-height form — for hosts that edit an
198
207
  * existing server-side record through `customSubmit` (configEdit, also when
@@ -224,7 +224,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
224
224
  return translate(template, values);
225
225
  }
226
226
  export function RenderEdit(props) {
227
- const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, 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, } = 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;
228
228
  const i18nScreenId = props.i18nScreenId ?? screen.id;
229
229
  const { customSubmit } = props;
230
230
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
@@ -357,6 +357,17 @@ export function RenderEdit(props) {
357
357
  ...(ctx !== undefined && { ctx }),
358
358
  ...(submitConfig !== undefined && { submit: submitConfig }),
359
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]);
360
371
  // Derived from the screen id + the host entity id only — never from
361
372
  // `vm.id`, which lives in the form values a restore mutates (load under one
362
373
  // key, save under another). Edit-mode: `${screen.id}:${entityIdProp}`,
@@ -902,7 +913,7 @@ export function RenderEdit(props) {
902
913
  // Locked state (#1896): the submit button is visibly disabled, but a
903
914
  // native form submit (Enter key) reaches this handler regardless of the
904
915
  // button's disabled attribute — block it here too, not just in the UI.
905
- if (disabled)
916
+ if (disabled || submitBlocked)
906
917
  return;
907
918
  // Sync guard — React state `isSubmitting` is too late for double-clicks
908
919
  // in the same tick (customSubmit has no submitInFlight of its own).
@@ -1161,7 +1172,8 @@ export function RenderEdit(props) {
1161
1172
  !insideDrawer &&
1162
1173
  submitWithoutChanges !== true) ||
1163
1174
  isSubmitting ||
1164
- 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 ??
1165
1177
  (isWizard
1166
1178
  ? "kumiko.actions.finish"
1167
1179
  : hasDirtyFooter
@@ -1342,7 +1354,7 @@ export function RenderEdit(props) {
1342
1354
  return null;
1343
1355
  const mount = (_jsx(RelatedListSection, { section: section, parentId: parentId,
1344
1356
  // @cast-boundary form-values: TValues ist strukturell ein Record.
1345
- 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 &&
1346
1358
  sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(onRelatedListDrawerAction !== undefined && {
1347
1359
  onOpenDrawer: onRelatedListDrawerAction,
1348
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
  }
@@ -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.356.0",
3
+ "version": "0.358.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.356.0",
19
- "@cosmicdrift/kumiko-headless": "0.356.0",
20
- "@cosmicdrift/kumiko-types": "0.356.0",
18
+ "@cosmicdrift/kumiko-framework": "0.358.0",
19
+ "@cosmicdrift/kumiko-headless": "0.358.0",
20
+ "@cosmicdrift/kumiko-types": "0.358.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.356.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.358.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,33 @@
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
+ },
2
31
  {
3
32
  "version": "0.356.0",
4
33
  "type": "improvement",