@cosmicdrift/kumiko-renderer 0.354.1 → 0.356.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.
@@ -1,6 +1,7 @@
1
1
  import type { EntityDefinition, EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type EditFormMode } from "@cosmicdrift/kumiko-headless";
2
3
  import * as z from "zod";
3
4
  export declare function isPresent(value: unknown): boolean;
4
5
  export declare const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
5
6
  export declare const OPTION_NOT_AVAILABLE_I18N_KEY = "kumiko.validation.optionNotAvailable";
6
- export declare function buildFormSchema(entity: EntityDefinition, screen: EntityEditScreenDefinition, unchangedFrom?: Readonly<Record<string, unknown>>): z.ZodType;
7
+ export declare function buildFormSchema(entity: EntityDefinition, screen: EntityEditScreenDefinition, verb: EditFormMode, unchangedFrom?: Readonly<Record<string, unknown>>): z.ZodType;
@@ -17,6 +17,26 @@ function isEmbeddedListField(field) {
17
17
  // Renders raw to the user without a de+en default in i18n-defaults.ts.
18
18
  export const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
19
19
  export const OPTION_NOT_AVAILABLE_I18N_KEY = "kumiko.validation.optionNotAvailable";
20
+ function isRequiredValueMissing(spec, field, record, unchangedFrom) {
21
+ // Screen-spec `required` overrides the entity default, mirroring
22
+ // `view-model/edit.ts` — the rendered form is the reference, and a
23
+ // presence check stricter than the form blocks the user for nothing.
24
+ const entityRequired = "required" in field && field.required === true;
25
+ const isRequired = spec.required === undefined ? entityRequired : evalFieldCondition(spec.required, record);
26
+ if (!isRequired)
27
+ return false;
28
+ // Embedded LIST fields get their own EmbeddedListField grid widget
29
+ // (#1838) — they're fillable, so NO_WIDGET_FIELD_TYPES's "embedded"
30
+ // entry must not exempt them. A statically-`required: true` field
31
+ // with no bound widget is also caught at boot
32
+ // (validateNoWidgetRequiredField in boot-validator/screens.ts).
33
+ if (NO_WIDGET_FIELD_TYPES.includes(field.type) && !isEmbeddedListField(field))
34
+ return false;
35
+ if (isPresent(record[spec.field]))
36
+ return false;
37
+ return !(unchangedFrom !== undefined &&
38
+ JSON.stringify(record[spec.field]) === JSON.stringify(unchangedFrom[spec.field]));
39
+ }
20
40
  // Client-side presence validation for the auto-wired entityEdit path —
21
41
  // checks that every rendered required field HAS a value, not that the
22
42
  // value has the right shape. Format/range/type validation stays server-
@@ -41,12 +61,15 @@ export const OPTION_NOT_AVAILABLE_I18N_KEY = "kumiko.validation.optionNotAvailab
41
61
  // fields via `computeFieldStates(options.fields, …)` (form-controller.ts:163,181),
42
62
  // fed by `deriveFormFields(screen)` in render-edit.tsx.
43
63
  //
64
+ // `verb` selects which `writeExcludedFields` list is skipped.
65
+ //
44
66
  // `unchangedFrom` is for payloadMode "changes" (update): a field still equal to
45
67
  // its loaded value never reaches the payload, so the server never presence-
46
68
  // checks it — a legacy row with a now-required empty field must stay saveable
47
69
  // when the user edits something else.
48
- export function buildFormSchema(entity, screen, unchangedFrom) {
70
+ export function buildFormSchema(entity, screen, verb, unchangedFrom) {
49
71
  const fields = layoutEditFields(screen);
72
+ const excludedFromWrite = new Set(screen.writeExcludedFields?.[verb] ?? []);
50
73
  return z
51
74
  .object({})
52
75
  .passthrough()
@@ -60,31 +83,16 @@ export function buildFormSchema(entity, screen, unchangedFrom) {
60
83
  const field = entity.fields[spec.field];
61
84
  if (!field)
62
85
  continue;
86
+ // Hidden on create, read-only on update: the user cannot resolve a presence error.
87
+ if (excludedFromWrite.has(spec.field))
88
+ continue;
63
89
  // Not operable by the user — a presence error would be unresolvable,
64
90
  // same reason as the NO_WIDGET_FIELD_TYPES check below.
65
91
  if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record))
66
92
  continue;
67
93
  selectFieldsToCheck[spec.field] = field;
68
- // Screen-spec `required` overrides the entity default, mirroring
69
- // `view-model/edit.ts` — the rendered form is the reference, and a
70
- // presence check stricter than the form blocks the user for nothing.
71
- const entityRequired = "required" in field && field.required === true;
72
- const isRequired = spec.required === undefined ? entityRequired : evalFieldCondition(spec.required, record);
73
- if (!isRequired)
74
- continue;
75
- // Embedded LIST fields get their own EmbeddedListField grid widget
76
- // (#1838) — they're fillable, so NO_WIDGET_FIELD_TYPES's "embedded"
77
- // entry must not exempt them. A statically-`required: true` field
78
- // with no bound widget is also caught at boot
79
- // (validateNoWidgetRequiredField in boot-validator/screens.ts).
80
- if (NO_WIDGET_FIELD_TYPES.includes(field.type) && !isEmbeddedListField(field))
81
- continue;
82
- if (isPresent(record[spec.field]))
83
- continue;
84
- if (unchangedFrom !== undefined &&
85
- JSON.stringify(record[spec.field]) === JSON.stringify(unchangedFrom[spec.field])) {
94
+ if (!isRequiredValueMissing(spec, field, record, unchangedFrom))
86
95
  continue;
87
- }
88
96
  ctx.addIssue({
89
97
  code: "custom",
90
98
  path: [spec.field],
@@ -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";
@@ -178,6 +179,13 @@ function entityWriteCommand(featureName, entity, verb) {
178
179
  function findEntityListScreen(schema, entityName) {
179
180
  return schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
180
181
  }
182
+ // Unknown or deleted record id: a localized empty state with a way back
183
+ // instead of a bare error banner. Composed from shared primitives so web and
184
+ // native render it alike.
185
+ function RecordNotFoundState({ translate, onBack, }) {
186
+ const { Card, Text, Button } = usePrimitives();
187
+ return (_jsxs(Card, { options: { padded: true }, testId: "kumiko-screen-record-missing", children: [_jsx(Text, { testId: "kumiko-screen-record-missing-title", children: translate("kumiko.record.not-found.title") }), _jsx(Text, { variant: "muted", children: translate("kumiko.record.not-found.hint") }), onBack !== undefined && (_jsx(Button, { variant: "secondary", onClick: onBack, testId: "kumiko-screen-record-missing-back", children: translate("kumiko.record.not-found.back") }))] }));
188
+ }
181
189
  function useNavigateToListAfter(schema, entityName) {
182
190
  const nav = useNav();
183
191
  return useCallback(() => {
@@ -658,7 +666,7 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
658
666
  moneyCurrencyOverrides,
659
667
  handoffValues,
660
668
  ]);
661
- const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
669
+ const formSchema = useMemo(() => buildFormSchema(entity, screen, "create"), [entity, screen]);
662
670
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
663
671
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
664
672
  const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
@@ -714,11 +722,15 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
714
722
  // `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
715
723
  // snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
716
724
  function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onCopyLink, autoReloadOnSave, onDeleted, }) {
717
- const { Banner, Text } = usePrimitives();
725
+ const { Banner } = usePrimitives();
718
726
  const t = useTranslation();
719
727
  const effectiveTranslate = translate ?? t;
720
728
  const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
721
729
  const detailQuery = useQuery(detailQn, { id: entityId });
730
+ const nav = useNav();
731
+ const navigateToList = useNavigateToListAfter(schema, screen.entity);
732
+ const returnTargetParam = useReturnTarget(screen.id);
733
+ const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
722
734
  if (detailQuery.loading && detailQuery.data === null) {
723
735
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
724
736
  }
@@ -727,7 +739,10 @@ function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onC
727
739
  }
728
740
  const record = detailQuery.data;
729
741
  if (!record) {
730
- return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-record-missing", children: ["Record ", _jsx(Text, { variant: "code", children: entityId }), " not found."] }));
742
+ const onBack = returnTarget !== undefined || findEntityListScreen(schema, screen.entity) !== undefined
743
+ ? () => navigateToReturnOr(nav, returnTarget, navigateToList)
744
+ : undefined;
745
+ return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(onBack !== undefined && { onBack }) }));
731
746
  }
732
747
  // Record-version als React-key: bei "Neu laden" refetched detail,
733
748
  // liefert neue version, und das Form remountet komplett. Ohne den
@@ -768,7 +783,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
768
783
  }
769
784
  return out;
770
785
  }, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
771
- const formSchema = useMemo(() => buildFormSchema(entity, screen, initial), [entity, screen, initial]);
786
+ const formSchema = useMemo(() => buildFormSchema(entity, screen, "update", initial), [entity, screen, initial]);
772
787
  // Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
773
788
  // damit sie beim Edit den Bestand zeigen statt write-only zu sein.
774
789
  const extensionInitialValues = useMemo(() => {
@@ -863,7 +878,8 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
863
878
  sameEntityScreenId,
864
879
  entityId,
865
880
  ]);
866
- const headerActions = useMemo(() => buildSectionActions(screen.actions ?? []), [buildSectionActions, screen.actions]);
881
+ const headerActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement !== "menu")), [buildSectionActions, screen.actions]);
882
+ const headerMenuActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement === "menu")), [buildSectionActions, screen.actions]);
867
883
  const handleSubmitted = useCallback((result) => {
868
884
  if (!result.isSuccess)
869
885
  return;
@@ -912,7 +928,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
912
928
  entityId: entityId,
913
929
  // Stored customFields for the extension section, so editing shows the
914
930
  // persisted values instead of behaving write-only.
915
- 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: () => {
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: () => {
916
932
  closeDrawer();
917
933
  void onReload();
918
934
  } })] }));
@@ -1880,47 +1896,10 @@ function primaryHeaderAction(actions) {
1880
1896
  // no button of its own to carry one.
1881
1897
  function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, PageHeader, }) {
1882
1898
  const t = useTranslation();
1883
- const [pendingAction, setPendingAction] = useState(null);
1884
- // Menu items have no button of their own to carry RenderEditActionButton's
1885
- // busy state; without it the menu can be reopened mid-write and fire the
1886
- // same writeHandler twice.
1887
- const [busyActionId, setBusyActionId] = useState(null);
1888
- const trigger = async (action) => {
1889
- onError(null);
1890
- setBusyActionId(action.id);
1891
- try {
1892
- await action.onPress();
1893
- }
1894
- catch (e) {
1895
- onError(e instanceof Error ? e.message : String(e));
1896
- }
1897
- finally {
1898
- setBusyActionId(null);
1899
- }
1900
- };
1901
- const toMenuItem = (action) => ({
1902
- id: action.id,
1903
- label: action.label,
1904
- ...(action.icon !== undefined && { icon: action.icon }),
1905
- variant: action.style === "danger" ? "danger" : "default",
1906
- ...(busyActionId !== null && { disabled: true }),
1907
- onSelect: () => {
1908
- if (needsActionConfirm(action)) {
1909
- setPendingAction(action);
1910
- }
1911
- else {
1912
- void trigger(action);
1913
- }
1914
- },
1899
+ const { toMenuItem, confirmDialog: pendingActionDialog } = useActionMenuItems({
1900
+ Dialog,
1901
+ onError,
1915
1902
  });
1916
- const pendingActionDialog = pendingAction !== null && (_jsx(RenderEditActionConfirmDialog, { action: pendingAction, open: true, onOpenChange: (open) => {
1917
- if (!open)
1918
- setPendingAction(null);
1919
- }, onConfirm: async () => {
1920
- const action = pendingAction;
1921
- setPendingAction(null);
1922
- await trigger(action);
1923
- }, Dialog: Dialog }));
1924
1903
  if (compact && PageHeader !== undefined) {
1925
1904
  const primary = primaryHeaderAction(actions);
1926
1905
  // A bare icon for a destructive or confirm-gated action (e.g. "x" for terminate)
@@ -2164,7 +2143,13 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2164
2143
  }
2165
2144
  const record = detailQuery.data;
2166
2145
  if (!record) {
2167
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-record-missing", children: screen.singleton === true ? ("Record not found.") : (_jsxs(_Fragment, { children: ["Record ", _jsx(Text, { variant: "code", children: entityId }), " not found."] })) }));
2146
+ const backListScreen = screen.listScreenId ??
2147
+ (screen.detailFor !== undefined
2148
+ ? findEntityListScreen(schema, screen.detailFor)?.id
2149
+ : undefined);
2150
+ return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(backListScreen !== undefined && {
2151
+ onBack: () => nav.navigate({ screenId: lastSegment(backListScreen) }),
2152
+ }) }));
2168
2153
  }
2169
2154
  // Grouped into the head Card alongside title/status/metrics (fw#2713):
2170
2155
  // these are actions on the record the head shows, not on whichever tab is
@@ -64,6 +64,11 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
64
64
  * same split as `onCopyLink`) — RenderEdit only wires the button, its
65
65
  * busy state and its confirm dialog. */
66
66
  readonly actions?: readonly RenderEditAction[];
67
+ /** Record actions declared with `placement: "menu"`, resolved like `actions`.
68
+ * Rendered in the header "…" menu ahead of copy-link/delete; without a
69
+ * header menu (drawer, card form, shell without a header slot) they fall
70
+ * back to buttons next to `actions`. */
71
+ readonly menuActions?: readonly RenderEditAction[];
67
72
  /** Opens a relatedList section row's drawer-kind action (fw#2710).
68
73
  * RenderEdit has no `schema` to resolve the target actionForm itself —
69
74
  * the caller (ProjectionDetailBody, which does have schema) supplies the
@@ -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, 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
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();
@@ -329,9 +331,15 @@ export function RenderEdit(props) {
329
331
  // Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
330
332
  // customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
331
333
  // weil wir controller.submit() eh nicht rufen.
332
- const omitFields = useMemo(() => layoutEditFields(screen)
333
- .filter((spec) => spec.submit === false)
334
- .map((spec) => spec.field), [screen]);
334
+ const writeVerb = isCreateMode ? "create" : "update";
335
+ // A write-excluded field must never reach the payload: the handler schema is
336
+ // z.never() for it, so even an unchanged read-only value would be rejected.
337
+ const omitFields = useMemo(() => [
338
+ ...layoutEditFields(screen)
339
+ .filter((spec) => spec.submit === false)
340
+ .map((spec) => spec.field),
341
+ ...(screen.writeExcludedFields?.[writeVerb] ?? []),
342
+ ], [screen, writeVerb]);
335
343
  const submitConfig = writeCommand !== undefined
336
344
  ? {
337
345
  type: writeCommand,
@@ -611,7 +619,8 @@ export function RenderEdit(props) {
611
619
  values: snapshot.values,
612
620
  translate,
613
621
  featureName,
614
- }), [screen, entity, snapshot.values, translate, featureName]);
622
+ mode: writeVerb,
623
+ }), [screen, entity, snapshot.values, translate, featureName, writeVerb]);
615
624
  const filteredSections = useMemo(
616
625
  // A fully-hidden "fields" section (every field in it currently
617
626
  // condition-hidden) must not occupy a wizard step; it would render
@@ -620,11 +629,12 @@ export function RenderEdit(props) {
620
629
  // run their own query / own submit), so they always pass through. A
621
630
  // section with no fields at all (e.g. a review-only step) has
622
631
  // `visible: fields.some(...)` = false vacuously; that's "no fields to
623
- // hide", not "hidden", so it stays too (fw#1901).
632
+ // hide", not "hidden", so it stays too (fw#1901). Unless the create
633
+ // exclusion emptied it: that would leave an empty wizard step / tab.
624
634
  () => filterEditSections(vm.sections, fieldsFilter).filter((section) => section.kind === "extension" ||
625
635
  section.kind === "relatedList" ||
626
636
  section.kind === "writeForm" ||
627
- section.fields.length === 0 ||
637
+ (section.fields.length === 0 && section.emptiedByWriteExclusion !== true) ||
628
638
  section.visible), [vm.sections, fieldsFilter]);
629
639
  const allFieldsRequired = useMemo(() => {
630
640
  const fillable = filteredSections.flatMap((section) => section.kind === "fields"
@@ -1033,6 +1043,7 @@ export function RenderEdit(props) {
1033
1043
  // action.
1034
1044
  const hasSecondaryFormActions = (!menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined)) ||
1035
1045
  (actions !== undefined && actions.length > 0) ||
1046
+ (!menuPageHeaderActions && menuActions !== undefined && menuActions.length > 0) ||
1036
1047
  (onCancel !== undefined && !cancelInHeaderMenu);
1037
1048
  // Teil-C collapse rule: driven by the custom `actions` group only — a
1038
1049
  // resolved true also folds the Copy-Link button (same visual group in the
@@ -1040,6 +1051,7 @@ export function RenderEdit(props) {
1040
1051
  const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
1041
1052
  const headerMenuItems = menuPageHeaderActions
1042
1053
  ? [
1054
+ ...(menuActions ?? []).map(menuActionToItem),
1043
1055
  ...(onCopyLink !== undefined
1044
1056
  ? [
1045
1057
  {
@@ -1083,7 +1095,8 @@ export function RenderEdit(props) {
1083
1095
  setLinkCopied(true);
1084
1096
  }, children: iconOnlyMidActions
1085
1097
  ? null
1086
- : 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") }))] }));
1098
+ : 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 &&
1099
+ 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") }))] }));
1087
1100
  const headerSlot = screen.slots?.header;
1088
1101
  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;
1089
1102
  // A function headerRegion places the slot itself (projectionDetail puts it
@@ -1219,7 +1232,7 @@ export function RenderEdit(props) {
1219
1232
  })
1220
1233
  : translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
1221
1234
  })();
1222
- return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
1235
+ return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [menuActionConfirmDialog, titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
1223
1236
  pageHeaderCompact && { overflowItems: headerMenuItems }), ...(headerMenuItems.length > 0 &&
1224
1237
  !pageHeaderCompact &&
1225
1238
  ActionOverflowMenu !== undefined && {
@@ -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
+ }
@@ -60,6 +60,9 @@ export const kumikoDefaultTranslations = {
60
60
  "kumiko.list.empty.title": "No entries yet.",
61
61
  "kumiko.list.empty.hint": "Create the first one to get started.",
62
62
  "kumiko.list.no-entries": "No entries.",
63
+ "kumiko.record.not-found.title": "This record was not found.",
64
+ "kumiko.record.not-found.hint": "It may have been deleted, or the link is no longer valid.",
65
+ "kumiko.record.not-found.back": "Back to list",
63
66
  "kumiko.list.end-of-list": "— End of list —",
64
67
  "kumiko.list.sort.label": "Sort",
65
68
  "kumiko.list.sort.unsorted": "Unsorted",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.354.1",
3
+ "version": "0.356.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.354.1",
19
- "@cosmicdrift/kumiko-headless": "0.354.1",
20
- "@cosmicdrift/kumiko-types": "0.354.1",
18
+ "@cosmicdrift/kumiko-framework": "0.356.0",
19
+ "@cosmicdrift/kumiko-headless": "0.356.0",
20
+ "@cosmicdrift/kumiko-types": "0.356.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.354.1"
31
+ "@cosmicdrift/kumiko-locale-de": "0.356.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.356.0",
4
+ "type": "improvement",
5
+ "title": "entityEdit actions can go into the header \"...\" menu via placement: \"menu\""
6
+ },
2
7
  {
3
8
  "version": "0.354.0",
4
9
  "type": "fix",