@cosmicdrift/kumiko-renderer 0.389.0 → 0.390.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.
Files changed (230) hide show
  1. package/dist/app/projection-detail-body.js +59 -394
  2. package/dist/app/projection-detail-header-model.d.ts +11 -0
  3. package/dist/app/projection-detail-header-model.js +33 -0
  4. package/dist/app/projection-detail-header-region.d.ts +28 -0
  5. package/dist/app/projection-detail-header-region.js +81 -0
  6. package/dist/app/projection-detail-header.d.ts +29 -0
  7. package/dist/app/projection-detail-header.js +84 -0
  8. package/dist/app/projection-detail-navigation.d.ts +13 -0
  9. package/dist/app/projection-detail-navigation.js +91 -0
  10. package/dist/app/projection-detail-states.d.ts +22 -0
  11. package/dist/app/projection-detail-states.js +26 -0
  12. package/dist/app/projection-drawer-host.d.ts +12 -0
  13. package/dist/app/projection-drawer-host.js +10 -0
  14. package/dist/app/projection-list-body.d.ts +1 -1
  15. package/dist/app/projection-list-body.js +69 -189
  16. package/dist/app/projection-list-render-props.d.ts +56 -0
  17. package/dist/app/projection-list-render-props.js +47 -0
  18. package/dist/app/projection-list-render.d.ts +26 -0
  19. package/dist/app/projection-list-render.js +20 -0
  20. package/dist/app/projection-list-row-click.d.ts +12 -0
  21. package/dist/app/projection-list-row-click.js +14 -0
  22. package/dist/app/use-projection-detail-actions.d.ts +35 -0
  23. package/dist/app/use-projection-detail-actions.js +156 -0
  24. package/dist/app/use-projection-detail-data.d.ts +19 -0
  25. package/dist/app/use-projection-detail-data.js +47 -0
  26. package/dist/app/use-projection-detail-layout.d.ts +17 -0
  27. package/dist/app/use-projection-detail-layout.js +23 -0
  28. package/dist/app/use-projection-list-actions.d.ts +31 -0
  29. package/dist/app/use-projection-list-actions.js +73 -0
  30. package/dist/app/use-projection-list-data.d.ts +20 -0
  31. package/dist/app/use-projection-list-data.js +26 -0
  32. package/dist/app/use-projection-list-definitions.d.ts +11 -0
  33. package/dist/app/use-projection-list-definitions.js +20 -0
  34. package/dist/app/use-projection-list-query.d.ts +28 -0
  35. package/dist/app/use-projection-list-query.js +83 -0
  36. package/dist/components/render-edit-banners.d.ts +12 -0
  37. package/dist/components/render-edit-banners.js +90 -0
  38. package/dist/components/render-edit-draft-save-helpers.d.ts +19 -0
  39. package/dist/components/render-edit-draft-save-helpers.js +42 -0
  40. package/dist/components/render-edit-field-grid.d.ts +4 -0
  41. package/dist/components/render-edit-field-grid.js +40 -0
  42. package/dist/components/render-edit-fields-section.d.ts +4 -0
  43. package/dist/components/render-edit-fields-section.js +76 -0
  44. package/dist/components/render-edit-form-actions.d.ts +4 -0
  45. package/dist/components/render-edit-form-actions.js +66 -0
  46. package/dist/components/render-edit-header-actions.d.ts +11 -0
  47. package/dist/components/render-edit-header-actions.js +120 -0
  48. package/dist/components/render-edit-layout-flags.d.ts +29 -0
  49. package/dist/components/render-edit-layout-flags.js +106 -0
  50. package/dist/components/render-edit-layout.d.ts +4 -0
  51. package/dist/components/render-edit-layout.js +79 -0
  52. package/dist/components/render-edit-render-model.d.ts +63 -0
  53. package/dist/components/render-edit-render-model.js +14 -0
  54. package/dist/components/render-edit-section-kinds.d.ts +4 -0
  55. package/dist/components/render-edit-section-kinds.js +115 -0
  56. package/dist/components/render-edit-submit-result.d.ts +8 -0
  57. package/dist/components/render-edit-submit-result.js +77 -0
  58. package/dist/components/render-edit-submit.d.ts +7 -0
  59. package/dist/components/render-edit-submit.js +113 -0
  60. package/dist/components/render-edit-titles.d.ts +17 -0
  61. package/dist/components/render-edit-titles.js +99 -0
  62. package/dist/components/render-edit-wizard-navigation.d.ts +11 -0
  63. package/dist/components/render-edit-wizard-navigation.js +115 -0
  64. package/dist/components/render-edit.d.ts +2 -2
  65. package/dist/components/render-edit.js +63 -1128
  66. package/dist/components/render-field-choice-inputs.d.ts +7 -0
  67. package/dist/components/render-field-choice-inputs.js +52 -0
  68. package/dist/components/render-field-control.d.ts +12 -0
  69. package/dist/components/render-field-control.js +93 -0
  70. package/dist/components/render-field-display-text.d.ts +4 -0
  71. package/dist/components/render-field-display-text.js +48 -0
  72. package/dist/components/render-field-fill-action.d.ts +13 -0
  73. package/dist/components/render-field-fill-action.js +63 -0
  74. package/dist/components/render-field-input-shared.d.ts +39 -0
  75. package/dist/components/render-field-input-shared.js +20 -0
  76. package/dist/components/render-field-input.d.ts +3 -0
  77. package/dist/components/render-field-input.js +34 -0
  78. package/dist/components/render-field-meta.d.ts +6 -0
  79. package/dist/components/render-field-meta.js +51 -0
  80. package/dist/components/render-field-read-only.d.ts +11 -0
  81. package/dist/components/render-field-read-only.js +75 -0
  82. package/dist/components/render-field-reference.d.ts +15 -0
  83. package/dist/components/render-field-reference.js +103 -0
  84. package/dist/components/render-field-scalar-inputs.d.ts +10 -0
  85. package/dist/components/render-field-scalar-inputs.js +35 -0
  86. package/dist/components/render-field-text-inputs.d.ts +3 -0
  87. package/dist/components/render-field-text-inputs.js +34 -0
  88. package/dist/components/render-field-values.d.ts +18 -0
  89. package/dist/components/render-field-values.js +111 -0
  90. package/dist/components/render-field-write-only.d.ts +9 -0
  91. package/dist/components/render-field-write-only.js +29 -0
  92. package/dist/components/render-field.d.ts +2 -2
  93. package/dist/components/render-field.js +39 -670
  94. package/dist/components/render-list-header-slot.d.ts +5 -0
  95. package/dist/components/render-list-header-slot.js +30 -0
  96. package/dist/components/render-list-reference-bridge.d.ts +3 -0
  97. package/dist/components/render-list-reference-bridge.js +25 -0
  98. package/dist/components/render-list-reference-columns.d.ts +25 -0
  99. package/dist/components/render-list-reference-columns.js +137 -0
  100. package/dist/components/render-list-table-props.d.ts +52 -0
  101. package/dist/components/render-list-table-props.js +53 -0
  102. package/dist/components/render-list-toolbar-model.d.ts +42 -0
  103. package/dist/components/render-list-toolbar-model.js +82 -0
  104. package/dist/components/render-list-toolbar.d.ts +38 -0
  105. package/dist/components/render-list-toolbar.js +40 -0
  106. package/dist/components/render-list-types.d.ts +113 -0
  107. package/dist/components/render-list-types.js +1 -0
  108. package/dist/components/render-list.d.ts +4 -113
  109. package/dist/components/render-list.js +91 -307
  110. package/dist/components/use-reference-input-state.d.ts +20 -0
  111. package/dist/components/use-reference-input-state.js +85 -0
  112. package/dist/components/use-render-edit-controlled-sync.d.ts +3 -0
  113. package/dist/components/use-render-edit-controlled-sync.js +26 -0
  114. package/dist/components/use-render-edit-draft-save.d.ts +29 -0
  115. package/dist/components/use-render-edit-draft-save.js +141 -0
  116. package/dist/components/use-render-edit-form.d.ts +10 -0
  117. package/dist/components/use-render-edit-form.js +101 -0
  118. package/dist/components/use-render-edit-sections.d.ts +15 -0
  119. package/dist/components/use-render-edit-sections.js +100 -0
  120. package/dist/components/use-render-edit-state.d.ts +65 -0
  121. package/dist/components/use-render-edit-state.js +158 -0
  122. package/dist/components/use-render-edit-steps.d.ts +13 -0
  123. package/dist/components/use-render-edit-steps.js +52 -0
  124. package/dist/components/use-render-list-columns.d.ts +18 -0
  125. package/dist/components/use-render-list-columns.js +41 -0
  126. package/dist/components/use-render-list-search.d.ts +4 -0
  127. package/dist/components/use-render-list-search.js +50 -0
  128. package/dist/primitives.d.ts +1 -1
  129. package/dist/web/app/dashboard-body.d.ts +1 -1
  130. package/dist/web/app/dashboard-body.js +42 -246
  131. package/dist/web/app/dashboard-header.d.ts +34 -0
  132. package/dist/web/app/dashboard-header.js +58 -0
  133. package/dist/web/app/dashboard-screen-controls.d.ts +11 -0
  134. package/dist/web/app/dashboard-screen-controls.js +42 -0
  135. package/dist/web/app/dashboard-screen-tiles.d.ts +8 -0
  136. package/dist/web/app/dashboard-screen-tiles.js +42 -0
  137. package/dist/web/app/dashboard-visibility.d.ts +42 -0
  138. package/dist/web/app/dashboard-visibility.js +102 -0
  139. package/dist/web/app/use-dashboard-screen-state.d.ts +35 -0
  140. package/dist/web/app/use-dashboard-screen-state.js +69 -0
  141. package/dist/web/primitives/data-table/actions-column.js +6 -5
  142. package/dist/web/primitives/data-table/card-body.d.ts +6 -0
  143. package/dist/web/primitives/data-table/card-body.js +32 -0
  144. package/dist/web/primitives/data-table/card-row.d.ts +5 -0
  145. package/dist/web/primitives/data-table/card-row.js +31 -0
  146. package/dist/web/primitives/data-table/card-view.d.ts +3 -0
  147. package/dist/web/primitives/data-table/card-view.js +13 -0
  148. package/dist/web/primitives/data-table/column-resize-handle.js +3 -1
  149. package/dist/web/primitives/data-table/data-cell-props.d.ts +7 -0
  150. package/dist/web/primitives/data-table/data-cell-props.js +8 -0
  151. package/dist/web/primitives/data-table/row-render-parts.d.ts +8 -0
  152. package/dist/web/primitives/data-table/row-render-parts.js +35 -0
  153. package/dist/web/primitives/data-table/table-body.d.ts +3 -0
  154. package/dist/web/primitives/data-table/table-body.js +32 -0
  155. package/dist/web/primitives/data-table/table-content.d.ts +3 -0
  156. package/dist/web/primitives/data-table/table-content.js +41 -0
  157. package/dist/web/primitives/data-table/table-data-cell.d.ts +4 -0
  158. package/dist/web/primitives/data-table/table-data-cell.js +25 -0
  159. package/dist/web/primitives/data-table/table-header-row.d.ts +3 -0
  160. package/dist/web/primitives/data-table/table-header-row.js +21 -0
  161. package/dist/web/primitives/data-table/table-view.d.ts +3 -0
  162. package/dist/web/primitives/data-table/table-view.js +25 -0
  163. package/dist/web/primitives/data-table/toolbar.d.ts +6 -0
  164. package/dist/web/primitives/data-table/toolbar.js +53 -0
  165. package/dist/web/primitives/data-table/use-data-table-state.d.ts +44 -0
  166. package/dist/web/primitives/data-table/use-data-table-state.js +123 -0
  167. package/dist/web/primitives/data-table.d.ts +3 -4
  168. package/dist/web/primitives/data-table.js +32 -252
  169. package/dist/web/primitives/file-value-list.js +1 -1
  170. package/dist/web/primitives/form-footer-parts.d.ts +25 -0
  171. package/dist/web/primitives/form-footer-parts.js +55 -0
  172. package/dist/web/primitives/form.js +33 -43
  173. package/dist/web/primitives/input-choice-kinds.d.ts +4 -0
  174. package/dist/web/primitives/input-choice-kinds.js +67 -0
  175. package/dist/web/primitives/input-file-kinds.d.ts +3 -0
  176. package/dist/web/primitives/input-file-kinds.js +6 -0
  177. package/dist/web/primitives/input-number-kinds.d.ts +4 -0
  178. package/dist/web/primitives/input-number-kinds.js +16 -0
  179. package/dist/web/primitives/input-optional-props.d.ts +20 -0
  180. package/dist/web/primitives/input-optional-props.js +24 -0
  181. package/dist/web/primitives/input-select-presentation.d.ts +13 -0
  182. package/dist/web/primitives/input-select-presentation.js +19 -0
  183. package/dist/web/primitives/input-shared.d.ts +22 -0
  184. package/dist/web/primitives/input-shared.js +10 -0
  185. package/dist/web/primitives/input-temporal-kinds.d.ts +5 -0
  186. package/dist/web/primitives/input-temporal-kinds.js +10 -0
  187. package/dist/web/primitives/input-text-kinds.d.ts +7 -0
  188. package/dist/web/primitives/input-text-kinds.js +33 -0
  189. package/dist/web/primitives/input.js +35 -161
  190. package/dist/web/widgets/chart-data.d.ts +35 -0
  191. package/dist/web/widgets/chart-data.js +37 -0
  192. package/dist/web/widgets/chart-markers.d.ts +26 -0
  193. package/dist/web/widgets/chart-markers.js +64 -0
  194. package/dist/web/widgets/chart-parts.d.ts +20 -0
  195. package/dist/web/widgets/chart-parts.js +15 -0
  196. package/dist/web/widgets/charts.d.ts +5 -78
  197. package/dist/web/widgets/charts.js +7 -485
  198. package/dist/web/widgets/drawer-panel-class.d.ts +3 -0
  199. package/dist/web/widgets/drawer-panel-class.js +40 -0
  200. package/dist/web/widgets/drawer-resize-controls.d.ts +11 -0
  201. package/dist/web/widgets/drawer-resize-controls.js +12 -0
  202. package/dist/web/widgets/drawer-sizing.d.ts +19 -0
  203. package/dist/web/widgets/drawer-sizing.js +60 -0
  204. package/dist/web/widgets/drawer.js +17 -116
  205. package/dist/web/widgets/stacked-area-brush.d.ts +13 -0
  206. package/dist/web/widgets/stacked-area-brush.js +91 -0
  207. package/dist/web/widgets/stacked-area-chart.d.ts +6 -0
  208. package/dist/web/widgets/stacked-area-chart.js +45 -0
  209. package/dist/web/widgets/stacked-area-chrome.d.ts +7 -0
  210. package/dist/web/widgets/stacked-area-chrome.js +77 -0
  211. package/dist/web/widgets/stacked-area-geometry.d.ts +54 -0
  212. package/dist/web/widgets/stacked-area-geometry.js +83 -0
  213. package/dist/web/widgets/stacked-area-model.d.ts +11 -0
  214. package/dist/web/widgets/stacked-area-model.js +1 -0
  215. package/dist/web/widgets/stacked-area-plot.d.ts +3 -0
  216. package/dist/web/widgets/stacked-area-plot.js +51 -0
  217. package/dist/web/widgets/stacked-area-props.d.ts +83 -0
  218. package/dist/web/widgets/stacked-area-props.js +16 -0
  219. package/dist/web/widgets/stacked-area-svg.d.ts +3 -0
  220. package/dist/web/widgets/stacked-area-svg.js +38 -0
  221. package/dist/web/widgets/stacked-area-tooltip.d.ts +3 -0
  222. package/dist/web/widgets/stacked-area-tooltip.js +86 -0
  223. package/dist/web/widgets/use-drawer-resize.d.ts +22 -0
  224. package/dist/web/widgets/use-drawer-resize.js +52 -0
  225. package/dist/web/widgets/use-stacked-area-tooltip.d.ts +9 -0
  226. package/dist/web/widgets/use-stacked-area-tooltip.js +34 -0
  227. package/dist/web/widgets/use-stacked-area-window.d.ts +20 -0
  228. package/dist/web/widgets/use-stacked-area-window.js +76 -0
  229. package/package.json +9 -9
  230. package/src/changes.json +54 -0
@@ -1,1135 +1,70 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { PROJECTION_DETAIL_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { ErrorReference } from "../app/error-reference.js";
5
- import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
6
1
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
7
- import { layoutEditFields } from "../app/layout-fields.js";
8
- import { useOptionalDispatcher } from "../context/dispatcher-context.js";
9
- import { useDraftStorage } from "../context/draft-storage-context.js";
10
- import { formatWhen } from "../format-when.js";
11
- import { computeEditViewModel, groupIssuesByPath } from "../headless/index.js";
12
- import { useForm } from "../hooks/use-form.js";
13
- import { useTranslation } from "../i18n.js";
14
- import { useInsideDrawer } from "../inside-drawer.js";
15
- import { useUnsavedInputSource } from "../leave-guard.js";
16
- import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
17
- import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
18
- import { EditSlotMount, ExtensionSectionMount } from "./edit-mounts.js";
19
- import { changedDerivedValues } from "./form-derivation-patch.js";
20
- import { GridCellForField } from "./grid-cell-for-field.js";
21
- import { RelatedListSection } from "./related-list-section.js";
22
- import { RenderEditActionButton } from "./render-edit-action-button.js";
23
- import { deriveFormFields, dryRunFieldIssues, dryRunParse, formSectionDomId, isFieldsStepFilled, resolveRecordTitle, resolveTitleTemplate, tabIdAt, wizardStepKey, } from "./render-edit-helpers.js";
24
- import { filterEditSections, findFirstErroringSectionIndex, hasEditableSection, hasIssueWithoutRenderedField, isDispatcherRejection, issuesAfterEdit, ROOT_ISSUE_PATH, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
25
- import { AllFieldsRequiredProvider } from "./render-field.js";
26
- import { useActionMenuItems } from "./use-action-menu-items.js";
27
- import { buildWizardDraftActions, PATCH_DRAFT_SAVE_DEBOUNCE_MS, useWizardFormDraftRestore, useWizardFormDraftState, } from "./use-wizard-form-draft.js";
28
- import { WriteFormSection } from "./write-form-section.js";
29
- export function RenderEdit(props) {
30
- 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: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, formDerivation, actionFormScreenQn, } = props;
31
- // View mode is a locked form, so everything keyed on `disabled` follows.
32
- const disabled = disabledProp || viewMode;
33
- const i18nScreenId = props.i18nScreenId ?? screen.id;
34
- const { customSubmit } = props;
35
- // Translate fallback: when the caller does not provide a translate function,
36
- // consume the i18next context directly. Otherwise field labels would remain raw
37
- // keys (`feature:entity:foo:field:title`) without caller wiring.
38
- // useTranslation throws without a LocaleProvider, which is acceptable because
39
- // RenderEdit only runs inside a mounted Kumiko app tree.
40
- const t = useTranslation();
41
- const translate = translateProp ?? t;
42
- const dispatcher = useOptionalDispatcher();
43
- const isWizard = screen.layout.mode === "wizard";
44
- // Tabs reuse the wizard's step machinery: one section visible at a time,
45
- // all of them mounted, `currentStep` as the active index. What they do NOT
46
- // share is navigation — a tab form submits from any tab, so every
47
- // step-transition path below stays wizard-only.
48
- const isTabs = screen.layout.mode === "tabs";
49
- const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
50
- const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
51
- const draftStorage = useDraftStorage();
52
- const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
53
- const [linkCopied, setLinkCopied] = useState(false);
54
- const [isSubmitting, setIsSubmitting] = useState(false);
55
- const [focusFirstErrorKey, setFocusFirstErrorKey] = useState(0);
56
- const [formError, setFormError] = useState(null);
57
- // Only a submit's validation/server error is cleared by editing; a delete
58
- // rejection and a version conflict (reload is the way out) stay put.
59
- const [formErrorSource, setFormErrorSource] = useState("submit");
60
- // A rejected delete can carry several blocking reasons; the first one is the
61
- // banner's headline (formError), the rest are listed beneath it.
62
- const [extraDeleteIssues, setExtraDeleteIssues] = useState([]);
63
- // One state for all header actions (actions?), not per-button — only one
64
- // action can be in flight at a time, and this keeps the error surfaced in
65
- // the shared formError-adjacent banner region instead of inside the
66
- // button row: a full-width Banner there breaks the
67
- // flex justify-end action bar.
68
- const [actionError, setActionError] = useState(null);
69
- const [rawStep, setRawStep] = useState(0);
70
- const [passedStepKeys, setPassedStepKeys] = useState(() => new Set());
71
- const [extensionStepComplete, setExtensionStepComplete] = useState({});
72
- const reportExtensionStepComplete = useCallback((stepKey, complete) => {
73
- setExtensionStepComplete((prev) => (prev[stepKey] === true) === complete ? prev : { ...prev, [stepKey]: complete });
74
- }, []);
75
- // Tabs have stable ids, so the active tab follows its id: a field change
76
- // that hides an earlier tab must not shift the index onto another tab.
77
- const [activeTabId, setActiveTabId] = useState(undefined);
78
- const draftState = useWizardFormDraftState({
79
- screenId: screen.id,
80
- entityId: entityIdProp,
81
- isCreateMode,
82
- draftStorage,
83
- });
84
- const { draftCandidates, setDraftCandidates } = draftState;
85
- // Composed save: extension sections report their dirty state so the Save button
86
- // activates when only a section changed, and provide a submit handler that runs
87
- // after the entity write. extensionErrorKey stores the i18n key for a failed
88
- // section persistence.
89
- //
90
- // This is not a second write path for saveDraft()'s data: deriveFormFields
91
- // skips extension sections entirely, so extension-owned field state never enters
92
- // `fields` or `controller.getSnapshot().values`; there is no draft-covered state
93
- // left for persistExtensions() to compete over.
94
- const [extensionDirty, setExtensionDirty] = useState(false);
95
- // Mirror for the leave guard: it must see "saved" before React re-renders.
96
- const extensionDirtyRef = useRef(false);
97
- const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
98
- const [extensionErrorKey, setExtensionErrorKey] = useState(null);
99
- const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(({ anyDirty, hasRegistrations }) => {
100
- extensionDirtyRef.current = anyDirty;
101
- setExtensionDirty(anyDirty);
102
- setHasExtensionRegistrations(hasRegistrations);
103
- });
104
- const { Button, Banner, Card, Dialog, Form, Section, Grid, GridCell, Text, Progress, StepBar, WizardStepGroup, Tabs, PageHeader, ActionOverflowMenu, } = usePrimitives();
105
- const { toMenuItem: menuActionToItem, confirmDialog: menuActionConfirmDialog } = useActionMenuItems({ Dialog, onError: setActionError });
106
- const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
107
- const pageHeaderCompact = usePageHeaderCompact();
108
- const insideDrawer = useInsideDrawer();
109
- // Both stepped layouts show one section at a time and keep the rest mounted
110
- // but inert. A tabs layout on a host without the Tabs primitive has no strip
111
- // to switch with, so it falls back to rendering every section stacked rather
112
- // than stranding the user on section 0. Same fallback when the
113
- // WizardStepGroup wrapper is missing.
114
- const isStepped = isWizard || (isTabs && Tabs !== undefined && WizardStepGroup !== undefined);
115
- const fields = useMemo(() => deriveFormFields(screen), [screen]);
116
- // Must be computed before submitConfig/useForm bakes it in (the controller
117
- // freezes it on first render). This is the unfiltered field-name set from
118
- // `fieldsFilter` ∩ `fields` — not the value-dependent `filteredSections`
119
- // (which also drops `section.visible === false`). Hidden fields still fall
120
- // out via form-controller's `hiddenFields` path. undefined = unscoped, since
121
- // scoping would silently drop root-level .refine() issues.
122
- // A `fieldsFilter` that matches nothing in `fields` (typo, renamed field)
123
- // must fall back to unscoped too — an empty scope array would filter out
124
- // ALL validation issues, letting submit() through
125
- // unvalidated on a form that also renders no sections.
126
- const scopeFieldNames = useMemo(() => {
127
- if (fieldsFilter === undefined)
128
- return undefined;
129
- const matched = fieldsFilter.filter((f) => Object.hasOwn(fields, f));
130
- return matched.length === 0 ? undefined : matched;
131
- }, [fieldsFilter, fields]);
132
- // Configure submission only when the caller provides a writeCommand; with
133
- // customSubmit, the form controller has no submit wiring because
134
- // controller.submit() is not called.
135
- const writeVerb = isCreateMode ? "create" : "update";
136
- // A write-excluded field must never reach the payload: the handler schema is
137
- // z.never() for it, so even an unchanged read-only value would be rejected.
138
- const omitFields = useMemo(() => [
139
- ...layoutEditFields(screen)
140
- .filter((spec) => spec.submit === false)
141
- .map((spec) => spec.field),
142
- ...(screen.writeExcludedFields?.[writeVerb] ?? []),
143
- ], [screen, writeVerb]);
144
- const submitConfig = writeCommand !== undefined
145
- ? {
146
- type: writeCommand,
147
- payloadMode,
148
- ...(buildPayload !== undefined && { buildPayload }),
149
- ...(omitFields.length > 0 && { omitFields }),
150
- ...(serverFieldPathPrefix !== undefined && { serverFieldPathPrefix }),
151
- ...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
152
- }
153
- : undefined;
154
- const { controller, snapshot } = useForm({
155
- initial,
156
- fields,
157
- ...(schema !== undefined && { schema }),
158
- ...(ctx !== undefined && { ctx }),
159
- ...(submitConfig !== undefined && { submit: submitConfig }),
160
- });
161
- // Derived fields are written into form state (not only displayed) so required
162
- // validation and the submit payload carry them; only a real change is set, which
163
- // keeps this from looping.
164
- useEffect(() => {
165
- if (formDerivation === undefined)
166
- return;
167
- const patch = changedDerivedValues(snapshot.values, formDerivation);
168
- if (patch !== undefined)
169
- controller.setValues(patch); // @cast-boundary form-values
170
- }, [snapshot.values, formDerivation, controller]);
171
- // Wired at the user-input boundary, not in controller.setField: computed
172
- // fields, prefill, draft restore and action patches also call setValues
173
- // after a failed submit and must not wipe the errors.
174
- const clearErrorsOnInput = (changedField) => {
175
- const errors = controller.getSnapshot().errors;
176
- const remaining = issuesAfterEdit(errors, changedField);
177
- if (remaining !== errors)
178
- controller.setErrors(remaining);
179
- if (formErrorSource === "submit" && formError?.code !== "version_conflict") {
180
- setFormError(null);
181
- }
182
- };
183
- const lastRefreshTokenRef = useRef(refreshToken);
184
- useEffect(() => {
185
- if (lastRefreshTokenRef.current === refreshToken)
186
- return;
187
- lastRefreshTokenRef.current = refreshToken;
188
- // Never overwrite user input: a dirty form keeps its values.
189
- if (controller.getSnapshot().isDirty)
190
- return;
191
- controller.setValues(initial);
192
- controller.rebase();
193
- }, [refreshToken, controller, initial]);
194
- // Controlled mode. Both callbacks live in refs so a
195
- // re-rendering caller (identity-unstable `onChange`/`onControlsReady`
196
- // closures, e.g. inline arrows) doesn't retrigger these effects — only a
197
- // real snapshot mutation (typing, patch()) does. Without this, a caller
198
- // whose onChange calls patch() to fill other fields (the VIN-decode use
199
- // case) would loop: patch → new snapshot → effect
200
- // re-subscribes with a "new" onChange → refires.
201
- const onChangeRef = useRef(onChange);
202
- onChangeRef.current = onChange;
203
- // Guards against redelivering to the SAME callback identity on every
204
- // render (an inline-arrow onControlsReady would otherwise refire the
205
- // effect below on every render since the callback itself is now a dep).
206
- // Tracks the actual prop, not a ref snapshot, so a caller that swaps in a
207
- // real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
208
- // gets delivered to for THIS mount instead of never.
209
- const deliveredControlsToRef = useRef(undefined);
210
- const onControlsReadyRef = useRef(onControlsReady);
211
- onControlsReadyRef.current = onControlsReady;
212
- const hasNavigatedRef = useRef(false);
213
- const submittedRef = useRef(false);
214
- const isSubmittingRef = useRef(false);
215
- const scopeFieldNamesRef = useRef(scopeFieldNames);
216
- scopeFieldNamesRef.current = scopeFieldNames;
217
- const scopedValidate = useCallback(() => controller.validate(scopeFieldNamesRef.current), [controller]);
218
- // The extension dirty flag aggregates every section; each section clears its
219
- // own part, so a host-side markSaved must not reset it.
220
- const markSaved = useCallback((fields) => {
221
- if (fields === undefined)
222
- controller.rebase();
223
- else
224
- controller.rebaseFields(fields);
225
- }, [controller]);
226
- // `saveDraft` (defined below) is a fresh closure every render, over that
227
- // render's `draftKey`/`draftId` — the ref lets the debounce timer below
228
- // always call the CURRENT one even though the timer was scheduled by an
229
- // earlier render's patch() call. `currentStepRef` gives it the current
230
- // wizard step without depending on `currentStep` (computed further down,
231
- // from `filteredSections`) at the point patchAndScheduleDraftSave itself
232
- // is defined — kept fresh where `currentStep` is computed below.
233
- const saveDraftRef = useRef(() => { });
234
- // `handleSubmit` (defined below) is a fresh closure over this render's
235
- // snapshot/extensionDirty, while the onControlsReady effect fires once per
236
- // mount — the ref lets controls.submit() always reach the current one.
237
- const handleSubmitRef = useRef(async () => { });
238
- const wizardNextRef = useRef(() => { });
239
- const wizardBackRef = useRef(() => { });
240
- const currentStepRef = useRef(0);
241
- const draftSaveTimerRef = useRef(null);
242
- useEffect(() => {
243
- return () => {
244
- if (draftSaveTimerRef.current !== null) {
245
- clearTimeout(draftSaveTimerRef.current);
246
- // Flush the pending debounced save instead of dropping it — a
247
- // navigation/unmount inside the debounce window would otherwise
248
- // silently discard the last patch() (discardDraft nulls this ref
249
- // first on the post-submit path, so this can't resurrect a
250
- // just-submitted draft).
251
- saveDraftRef.current(currentStepRef.current);
252
- }
253
- };
254
- }, []);
255
- // patch() (controlled mode, extension sections) applies immediately —
256
- // only the resulting draft save is debounced, so a patch() burst
257
- // (VIN-decode filling several fields, or onChange fanning a keystroke out
258
- // through patch(), see the VIN-decode test above) collapses into one save.
259
- const patchAndScheduleDraftSave = useCallback((partial) => {
260
- controller.setValues(partial);
261
- if (!draftEnabled || dispatcher === undefined || disabled || submittedRef.current)
262
- return;
263
- if (draftSaveTimerRef.current !== null)
264
- clearTimeout(draftSaveTimerRef.current);
265
- draftSaveTimerRef.current = setTimeout(() => {
266
- draftSaveTimerRef.current = null;
267
- if (disabled || submittedRef.current)
268
- return;
269
- saveDraftRef.current(currentStepRef.current);
270
- }, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
271
- }, [controller, draftEnabled, dispatcher, disabled]);
272
- // Runs before the onChange effect below (declaration order = React
273
- // execution order) so that an onChange fired on mount always sees
274
- // `controls !== undefined` — both effects depend only on mount-stable
275
- // values (controller/scopedValidate/patchAndScheduleDraftSave), so the
276
- // swap is otherwise a no-op. See the VIN-decode test: an initial
277
- // value that should derive dependent fields on mount needs controls.patch
278
- // available on the very first onChange call, not just from the second
279
- // keystroke onward.
280
- const hasControlsReady = onControlsReady !== undefined;
281
- useEffect(() => {
282
- if (!hasControlsReady)
283
- return;
284
- const ready = onControlsReadyRef.current;
285
- if (ready === undefined)
286
- return;
287
- if (deliveredControlsToRef.current === ready)
288
- return;
289
- deliveredControlsToRef.current = ready;
290
- ready({
291
- patch: patchAndScheduleDraftSave,
292
- validate: scopedValidate,
293
- getValues: () => controller.getSnapshot().values,
294
- // saveNow: a host-driven submit must save on a wizard's intermediate
295
- // steps too instead of silently advancing; stepping is next()/back().
296
- submit: () => handleSubmitRef.current({ saveNow: true }),
297
- next: () => wizardNextRef.current(),
298
- back: () => wizardBackRef.current(),
299
- });
300
- // Dep on boolean presence only — inline-arrow identity must not redeliver.
301
- }, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
302
- // `schema` is typically an inline caller prop (`schema={z.object({...})}`)
303
- // with unstable identity across renders — as an effect dep it would refire
304
- // on every parent render, not just on a snapshot change, risking a loop if
305
- // the caller's onChange triggers a parent re-render. Held in a ref like
306
- // onChangeRef so only a real snapshot mutation retriggers this effect.
307
- const schemaRef = useRef(schema);
308
- schemaRef.current = schema;
309
- // Controls are guaranteed ready by the time this fires (see the
310
- // onControlsReady effect above, which is declared first on purpose).
311
- useEffect(() => {
312
- const cb = onChangeRef.current;
313
- if (cb === undefined)
314
- return;
315
- // Dry-run parse against `schema` — NOT controller.validate(). Calling
316
- // validate() here would write field-level errors into snapshot.errors
317
- // on every keystroke, painting error messages while the user is still
318
- // typing. `valid` can therefore legitimately diverge from what's
319
- // currently rendered under the fields (the last *mutating* validate()
320
- // call, e.g. from controls.validate() or submit()). The issues are
321
- // filtered exactly like submit()'s validate: hidden fields and fields
322
- // outside the `fields` scope never make the form invalid.
323
- const currentSchema = schemaRef.current;
324
- const valid = currentSchema === undefined ||
325
- dryRunFieldIssues(currentSchema, snapshot, scopeFieldNamesRef.current, true).length === 0;
326
- cb({
327
- values: snapshot.values,
328
- changes: snapshot.changes,
329
- dirty: snapshot.isDirty,
330
- valid,
331
- submitting: isSubmitting,
332
- });
333
- }, [snapshot, isSubmitting]);
334
- const onDirtyChangeRef = useRef(onDirtyChange);
335
- onDirtyChangeRef.current = onDirtyChange;
336
- const hasUnsavedInput = snapshot.isDirty || extensionDirty;
337
- useEffect(() => {
338
- onDirtyChangeRef.current?.(hasUnsavedInput);
339
- }, [hasUnsavedInput]);
340
- // Draft wizards keep their input server-side, so leaving loses nothing.
341
- const persistsDraft = isWizard && screen.layout.draft === true;
342
- useUnsavedInputSource(() => !persistsDraft && (controller.getSnapshot().isDirty || extensionDirtyRef.current));
343
- useWizardFormDraftRestore({
344
- screenId: screen.id,
345
- isCreateMode,
346
- draftEnabled,
347
- dispatcher,
348
- controller,
349
- hasNavigatedRef,
350
- setRawStep,
351
- draftState,
2
+ import { renderEditLayout } from "./render-edit-layout.js";
3
+ import { deriveFooterFlags, deriveLayoutFlags } from "./render-edit-layout-flags.js";
4
+ import { resolveRenderEditProps, } from "./render-edit-render-model.js";
5
+ import { createHandleSubmit } from "./render-edit-submit.js";
6
+ import { deriveTitles } from "./render-edit-titles.js";
7
+ import { createWizardNavigation } from "./render-edit-wizard-navigation.js";
8
+ import { useRenderEditControlledSync } from "./use-render-edit-controlled-sync.js";
9
+ import { useRenderEditDraftSave } from "./use-render-edit-draft-save.js";
10
+ import { useRenderEditForm } from "./use-render-edit-form.js";
11
+ import { useRenderEditSections } from "./use-render-edit-sections.js";
12
+ import { useRenderEditState } from "./use-render-edit-state.js";
13
+ import { useRenderEditSteps } from "./use-render-edit-steps.js";
14
+ import { buildWizardDraftActions } from "./use-wizard-form-draft.js";
15
+ function footerSlotName(screen) {
16
+ const footerSlot = screen.slots?.footer;
17
+ return footerSlot !== undefined ? extensionSectionName(footerSlot) : undefined;
18
+ }
19
+ export function RenderEdit(rawProps) {
20
+ const props = resolveRenderEditProps(rawProps);
21
+ const state = useRenderEditState({
22
+ screen: props.screen,
23
+ entityId: props.entityId,
24
+ translate: props.translate,
352
25
  });
353
- const vm = useMemo(() => computeEditViewModel({
354
- screen,
355
- entity,
356
- values: snapshot.values,
357
- translate,
358
- featureName,
359
- mode: writeVerb,
360
- ...(!isCreateMode && entityIdProp != null && { entityId: entityIdProp }),
361
- ...(formDerivation !== undefined && { derivation: formDerivation }),
362
- ...(actionFormScreenQn !== undefined && { actionFormScreenQn }),
363
- }), [
364
- formDerivation,
365
- actionFormScreenQn,
366
- screen,
367
- entity,
368
- snapshot.values,
369
- translate,
370
- featureName,
371
- writeVerb,
372
- isCreateMode,
373
- entityIdProp,
374
- ]);
375
- const filteredSections = useMemo(
376
- // A fully-hidden "fields" section (every field in it currently
377
- // condition-hidden) must not occupy a wizard step; it would render
378
- // empty and block Back/Next on nothing. Extension, relatedList and
379
- // writeForm sections carry no `visible` (they own their own lifecycle /
380
- // run their own query / own submit), so they always pass through. A
381
- // section with no fields at all (e.g. a review-only step) has
382
- // `visible: fields.some(...)` = false vacuously; that's "no fields to
383
- // hide", not "hidden", so it stays too. Unless the create
384
- // exclusion emptied it: that would leave an empty wizard step / tab.
385
- () => filterEditSections(vm.sections, fieldsFilter).filter((section) => section.kind === "extension" ||
386
- section.kind === "relatedList" ||
387
- section.kind === "writeForm" ||
388
- (section.fields.length === 0 && section.emptiedByWriteExclusion !== true) ||
389
- section.visible), [vm.sections, fieldsFilter]);
390
- const allFieldsRequired = useMemo(() => {
391
- const fillable = filteredSections.flatMap((section) => section.kind === "fields"
392
- ? section.fields.filter((field) => field.visible && !field.readOnly && field.type !== "boolean")
393
- : []);
394
- return fillable.length > 0 && fillable.every((field) => field.required);
395
- }, [filteredSections]);
396
- const showsAllRequiredHint = allFieldsRequired && hideSectionTitles !== true && !isWizard;
397
- // true when editable fields exist or an extension opted into composed submit.
398
- const isFormEditable = hasEditableSection(filteredSections);
399
- // A fieldless form (input-less secretMint mint — the secret is
400
- // server-generated, the mint step is only its submit button) has no
401
- // section to gate on: it IS the action, so it always shows the submit
402
- // button, and it can never go dirty either.
403
- const isFieldless = Object.keys(entity.fields).length === 0;
404
- // A lone relatedList tab (`hideSectionTitles` is only ever set by
405
- // ProjectionDetailBody, which hands this component ONE section — its
406
- // synthesized screen drops layout.mode, so an entityEdit tabs layout never
407
- // reaches this line and `filteredSections[0]` stays the active section)
408
- // needs its own tab panel capped at the available height so its table
409
- // scrolls inside the panel instead of the whole page stretching to the
410
- // row count — a short table still sizes to its content instead
411
- // of stretching the panel to the bottom. Any other layout —
412
- // multiple sections, a non-relatedList tab, stacked (non-tabs) forms —
413
- // keeps normal document-flow height untouched.
414
- const fillHeight = fillScreenHeight === true ||
415
- (hideSectionTitles === true && filteredSections[0]?.kind === "relatedList");
416
- // A lone writeForm tab is the whole page, so its submit joins this form's
417
- // footer (pinned to the window edge like every other screen's save)
418
- // instead of sitting in the section's title row.
419
- const isLoneWriteFormTab = hideSectionTitles === true && filteredSections[0]?.kind === "writeForm";
420
- const [writeFormFooterAction, setWriteFormFooterAction] = useState(undefined);
421
- // Persist all composed extension sections using the resolved entityId.
422
- // false means a section failed and its i18n key is shown in the banner. Without
423
- // entity context (create mode without a route id), there is nothing to write.
424
- async function persistExtensions() {
425
- const entityId = resolveExtensionEntityId(entityIdProp, vm.id);
426
- if (entityId === null)
427
- return true;
428
- const results = await runExtensionSubmits({ entityId });
429
- const failed = results.find((r) => !r.isSuccess);
430
- if (failed !== undefined) {
431
- setExtensionErrorKey(failed.errorKey ?? "kumiko.form.extension.save-failed");
432
- return false;
433
- }
434
- return true;
435
- }
436
- const lastStepIndex = Math.max(filteredSections.length - 1, 0);
437
- // Clamped on read, not on write: section visibility is value-dependent, so a
438
- // stored stepIndex can point past what is currently rendered — that lands on
439
- // the last step instead of an empty one.
440
- const activeTabIndex = isTabs && activeTabId !== undefined
441
- ? filteredSections.findIndex((s, i) => tabIdAt(s, i) === activeTabId)
442
- : -1;
443
- const currentStep = activeTabIndex !== -1 ? activeTabIndex : Math.min(rawStep, lastStepIndex);
444
- // Kept fresh for patchAndScheduleDraftSave's debounce timer above, which
445
- // is defined before `currentStep` exists (it depends on `filteredSections`,
446
- // computed further up from `vm`) and so cannot close over it directly.
447
- currentStepRef.current = currentStep;
448
- const isLastWizardStep = currentStep >= lastStepIndex;
449
- const wizardDoneSteps = useMemo(() => {
450
- if (!isWizard || isCreateMode)
451
- return undefined;
452
- const parse = schema === undefined
453
- ? undefined
454
- : dryRunParse(schema, { values: snapshot.values, fields: snapshot.fields });
455
- return filteredSections.map((section, index) => {
456
- if (passedStepKeys.has(wizardStepKey(section, index)))
457
- return true;
458
- if (section.kind === "fields")
459
- return isFieldsStepFilled(section, parse);
460
- return (section.kind === "extension" &&
461
- extensionStepComplete[wizardStepKey(section, index)] === true);
462
- });
463
- }, [
464
- isWizard,
465
- isCreateMode,
466
- filteredSections,
467
- passedStepKeys,
468
- extensionStepComplete,
469
- schema,
470
- snapshot.values,
471
- snapshot.fields,
472
- ]);
473
- const updateModeStepBarProps = wizardDoneSteps === undefined
474
- ? {}
475
- : { doneSteps: wizardDoneSteps, selectableSteps: "all" };
476
- function jumpToStep(index) {
477
- setRawStep(index);
478
- if (isTabs)
479
- setActiveTabId(tabIdAt(filteredSections[index], index));
480
- }
481
- const { adoptDraft, saveDraft, discardDraft } = buildWizardDraftActions({
482
- screenId: screen.id,
483
- isCreateMode,
484
- draftEnabled,
485
- disabled,
486
- dispatcher,
487
- draftStorage,
488
- controller,
489
- sections: vm.sections,
490
- draftSaveTimerRef,
491
- draftState,
26
+ const form = useRenderEditForm(props, state);
27
+ const draft = useRenderEditDraftSave(props, state, form);
28
+ const core = { props, state, form, draft };
29
+ useRenderEditControlledSync(core);
30
+ const sections = useRenderEditSections(core);
31
+ const steps = useRenderEditSteps({ ...core, sections });
32
+ const stepped = { ...core, sections, steps };
33
+ const draftActions = buildWizardDraftActions({
34
+ screenId: props.screen.id,
35
+ isCreateMode: state.isCreateMode,
36
+ draftEnabled: state.draftEnabled,
37
+ disabled: props.disabled,
38
+ dispatcher: state.dispatcher,
39
+ draftStorage: state.draftStorage,
40
+ controller: form.controller,
41
+ sections: sections.vm.sections,
42
+ draftSaveTimerRef: draft.refs.draftSaveTimerRef,
43
+ draftState: state.draftState,
492
44
  });
493
- saveDraftRef.current = saveDraft;
494
- // Next: scoped validate() on the current step's fields — errors stay
495
- // attached to the field and block the step transition. Extension steps
496
- // have no field scope here (they validate via the controlled-mode
497
- // controls.validate() API), so no call, no clearing of unrelated errors.
498
- function handleWizardNext() {
499
- const next = Math.min(currentStep + 1, lastStepIndex);
500
- hasNavigatedRef.current = true;
501
- // Locked state: `disabled` means "no input/no write", not "no
502
- // navigation" — a disabled wizard must still be steppable so Back/Next
503
- // reach every step. But neither validate() (would block on an empty
504
- // required field nobody can fill in) nor saveDraft() (would mint a
505
- // draftId / dispatch a write — exactly what `disabled` forbids) may run.
506
- if (disabled) {
507
- setRawStep(next);
508
- return;
509
- }
510
- // skip: the current step has field errors — no transition, no draft save.
511
- if (currentStepHasFieldErrors())
512
- return;
513
- if (!isCreateMode) {
514
- const leftSection = filteredSections[currentStep];
515
- if (leftSection !== undefined) {
516
- const leftKey = wizardStepKey(leftSection, currentStep);
517
- setPassedStepKeys((prev) => (prev.has(leftKey) ? prev : new Set(prev).add(leftKey)));
518
- }
519
- }
520
- setRawStep(next);
521
- saveDraft(next);
522
- }
523
- function currentStepHasFieldErrors() {
524
- const section = filteredSections[currentStep];
525
- const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
526
- return fieldNames.length > 0 && !controller.validate(fieldNames);
527
- }
528
- function handleWizardBack() {
529
- handleWizardStepSelect(currentStep - 1);
530
- }
531
- // Update mode: the record already exists and the final Save validates the
532
- // whole form and jumps to the first erroring step (findFirstErroringStep), so
533
- // skipping steps forward is safe; leaving the current step still goes through
534
- // its validate gate.
535
- function handleWizardStepSelect(targetStep) {
536
- if (targetStep === currentStep)
537
- return;
538
- const isForward = targetStep > currentStep;
539
- // Create mode: a forward jump would skip the per-step validate() gate.
540
- if (isForward && isCreateMode)
541
- return;
542
- if (isForward && !disabled && currentStepHasFieldErrors())
543
- return;
544
- const clampedTarget = Math.min(Math.max(targetStep, 0), lastStepIndex);
545
- hasNavigatedRef.current = true;
546
- setRawStep(clampedTarget);
547
- if (!disabled)
548
- saveDraft(clampedTarget);
549
- }
550
- // Stepped modes (wizard, tabs): locates the first step whose "fields"
551
- // section contains one of the given field paths — matched on the path's
552
- // first segment, since `FieldIssue.path`/`snapshot.errors` keys can be
553
- // dotted (embedded-list rows, e.g. `tasks.2.title`) while a section's field
554
- // name is always the plain top-level name. Returns undefined for a
555
- // root-level issue (path `"(root)"`, see zod-bridge.ts) or one that matches
556
- // no rendered field — callers must NOT suppress the error banner in that
557
- // case, there is nowhere to jump the user to. Extension sections are not
558
- // jump targets by design: they validate through the controlled-mode
559
- // controls.validate() API and own no field specs to match against.
560
- function findFirstErroringStep(fieldPaths) {
561
- return findFirstErroringSectionIndex(filteredSections, fieldPaths);
562
- }
563
- async function handleSubmit(options) {
564
- // Enter in the active step triggers the native form submit (Next is
565
- // type="submit" for Enter support) — on intermediate steps that means
566
- // "Next", not "Save". Checked BEFORE the `disabled` guard below:
567
- // `disabled` means "no input/no write", not "no navigation" — a
568
- // disabled wizard must still be steppable (handleWizardNext itself
569
- // blocks validate()/saveDraft() while disabled). `saveNow` is the explicit
570
- // "Save and close" button and skips the step advance.
571
- if (isWizard && !isLastWizardStep && options?.saveNow !== true) {
572
- handleWizardNext();
573
- return;
574
- }
575
- // Locked state: the submit button is visibly disabled, but a
576
- // native form submit (Enter key) reaches this handler regardless of the
577
- // button's disabled attribute — block it here too, not just in the UI.
578
- if (disabled || submitBlocked)
579
- return;
580
- // Sync guard — React state `isSubmitting` is too late for double-clicks
581
- // in the same tick (customSubmit has no submitInFlight of its own).
582
- if (isSubmittingRef.current)
583
- return;
584
- isSubmittingRef.current = true;
585
- setIsSubmitting(true);
586
- setExtensionErrorKey(null);
587
- try {
588
- // Extension-only: only a section is dirty and the main form is unchanged.
589
- // Skip the entity write (it would send an empty changes payload) and only
590
- // run the section handlers.
591
- if (snapshot.isUnchanged && extensionDirty) {
592
- // Same discard as the main path: an extension-only save is still a
593
- // successful submit, so the draft must not survive it.
594
- if (await persistExtensions()) {
595
- extensionDirtyRef.current = false;
596
- submittedRef.current = true;
597
- await discardDraft();
598
- }
599
- return;
600
- }
601
- let result;
602
- if (customSubmit !== undefined) {
603
- // customSubmit path (e.g. configEdit, which performs a separate write per
604
- // field). Validate on the client first, then call the caller; on success,
605
- // explicitly rebase the form state because controller.submit() normally
606
- // does this itself, and the controller otherwise knows nothing about a
607
- // successful custom submission (isUnchanged would remain false).
608
- //
609
- // Read the snapshot directly from the controller rather than React state.
610
- // With rapid fill-then-click input, React batching may not have committed
611
- // state updates when the submit click fires. handleSubmit's closure could otherwise
612
- // see stale snapshot.changes={} and perform no writes, then return success
613
- // and rebase the form, making it appear that the data was saved.
614
- // controller.getSnapshot() is always current: the controller is the
615
- // source of truth, while React state is only a rendering mirror.
616
- const valid = controller.validate(scopeFieldNames);
617
- if (!valid) {
618
- // Field order matters: validationBlocked:true is its own
619
- // variant in the SubmitResult union (NOT mixed with data/error);
620
- // TS only narrows cleanly without a discriminator fight.
621
- const blocked = {
622
- validationBlocked: true,
623
- isSuccess: false,
624
- };
625
- result = blocked;
626
- }
627
- else {
628
- result = await customSubmit(controller.getSnapshot());
629
- if (result.isSuccess)
630
- controller.rebase();
631
- }
632
- }
633
- else {
634
- result = await controller.submit();
635
- }
636
- // Form-level errors (without field-level details) go to the banner.
637
- // Field errors flow via snapshot.errors into the individual fields.
638
- let extensionsPersisted = true;
639
- if (result.isSuccess) {
640
- setFormError(null);
641
- setExtraDeleteIssues([]);
642
- // `isNoOp: true` (payloadMode "changes", pre-filled form submitted
643
- // untouched) means controller.submit() never called dispatcher.write
644
- // — nothing to discard, and discarding here would delete a draft the
645
- // form never persisted. Awaited, not fire-and-forget:
646
- // `onSubmit` typically navigates away and unmounts this form, which
647
- // would abort an in-flight discard and leave the draft behind after
648
- // a successful submit.
649
- if (result.isNoOp !== true) {
650
- submittedRef.current = true;
651
- await discardDraft();
652
- }
653
- extensionsPersisted = await persistExtensions();
654
- if (extensionsPersisted)
655
- extensionDirtyRef.current = false;
656
- }
657
- else if (result.validationBlocked) {
658
- setFocusFirstErrorKey((key) => key + 1);
659
- // Root-level `.refine()`/cross-field issues from controller.validate()
660
- // — same "jump to the erroring step" treatment as a server field
661
- // error below, sourced from the freshly-validated snapshot.
662
- if (isStepped) {
663
- const step = findFirstErroringStep(Object.keys(controller.getSnapshot().errors));
664
- if (step !== undefined)
665
- jumpToStep(step);
666
- }
667
- }
668
- else {
669
- const fieldIssues = result.error.details?.fields ?? [];
670
- const issuePaths = fieldIssues.map((i) => i.path);
671
- if (fieldIssues.length > 0)
672
- setFocusFirstErrorKey((key) => key + 1);
673
- // controller.submit() writes server issues into the controller itself;
674
- // customSubmit bypasses it, so push them here to reach the fields.
675
- if (customSubmit !== undefined && fieldIssues.length > 0) {
676
- controller.setErrors(groupIssuesByPath(fieldIssues));
677
- }
678
- // A server field error on a step the form isn't currently showing is
679
- // otherwise invisible — jump to the first step that contains one of
680
- // the errored fields. For tabs this is what keeps the submit from
681
- // blocking in silence, the failure mode the boot-validator used to
682
- // prevent by rejecting the layout outright. An issue no
683
- // rendered field can show (`version`, `id`, a root-level refine, a
684
- // hidden field) keeps the banner, jump or not.
685
- if (isStepped) {
686
- const step = findFirstErroringStep(issuePaths);
687
- if (step !== undefined)
688
- jumpToStep(step);
689
- }
690
- setExtraDeleteIssues([]);
691
- setFormErrorSource("submit");
692
- setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections, new Set(Object.keys(formDerivation?.derive ?? {})))
693
- ? result.error
694
- : null);
695
- }
696
- // skip: on entity-success-but-extension-failure the extension-error
697
- // banner is showing — don't notify (the caller navigates away on success
698
- // and would unmount it before the user sees the failure).
699
- if (shouldNotifyCaller(result, extensionsPersisted)) {
700
- onSubmit?.(result, controller.getSnapshot().values);
701
- }
702
- }
703
- finally {
704
- isSubmittingRef.current = false;
705
- setIsSubmitting(false);
706
- }
707
- }
708
- handleSubmitRef.current = handleSubmit;
709
- wizardNextRef.current = isWizard ? handleWizardNext : () => { };
710
- wizardBackRef.current = isWizard ? handleWizardBack : () => { };
711
- // Screen forms (entityEdit/actionForm filling the shell height) drop the card;
712
- // the title then lives in the shell header when the shell offers a slot.
713
- const rootIssues = snapshot.errors[ROOT_ISSUE_PATH] ?? [];
714
- const isScreenForm = fillScreenHeight === true && hideSectionTitles !== true && !insideDrawer;
715
- const titleInShell = PageHeader !== undefined &&
716
- pageHeaderSlotAvailable &&
717
- hideSectionTitles !== true &&
718
- headerRegion === undefined;
719
- const menuPageHeaderActions = titleInShell && isScreenForm && ActionOverflowMenu !== undefined;
720
- const cancelInHeaderMenu = menuPageHeaderActions && isWizard && onCancel !== undefined;
721
- // Two groups — wizard navigation on the right/top, record actions on the
722
- // left/below; destructive action sits outermost, farthest from the target
723
- // action.
724
- const hasSecondaryFormActions = (!menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined)) ||
725
- (actions !== undefined && actions.length > 0) ||
726
- (!menuPageHeaderActions && menuActions !== undefined && menuActions.length > 0) ||
727
- (onCancel !== undefined && !cancelInHeaderMenu);
728
- // Teil-C collapse rule: driven by the custom `actions` group only — a
729
- // resolved true also folds the Copy-Link button (same visual group in the
730
- // footer), but never Delete/Cancel, which keep their text regardless.
731
- const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
732
- const headerMenuItems = menuPageHeaderActions
733
- ? [
734
- ...(menuActions ?? []).map(menuActionToItem),
735
- ...(onCopyLink !== undefined
736
- ? [
737
- {
738
- id: "copy-link",
739
- label: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink"),
740
- icon: "link",
741
- onSelect: () => {
742
- void Promise.resolve(onCopyLink()).then(() => setLinkCopied(true));
743
- },
744
- },
745
- ]
746
- : []),
747
- ...(cancelInHeaderMenu
748
- ? [
749
- {
750
- id: "cancel",
751
- label: translate("kumiko.actions.cancel"),
752
- icon: "x",
753
- onSelect: () => onCancel(),
754
- },
755
- ]
756
- : []),
757
- ...(onDelete !== undefined
758
- ? [
759
- {
760
- id: "delete",
761
- label: translate("kumiko.actions.delete"),
762
- icon: "trash",
763
- variant: "danger",
764
- onSelect: () => setConfirmDeleteOpen(true),
765
- },
766
- ]
767
- : []),
768
- ]
769
- : [];
770
- const secondaryFormActions = (_jsxs(_Fragment, { children: [onDelete !== undefined && !menuPageHeaderActions && (_jsx(Button, { type: "button", variant: "danger-ghost", icon: "trash", testId: "render-edit-delete", disabled: disabled, onClick: () => setConfirmDeleteOpen(true), children: translate("kumiko.actions.delete") })), onCopyLink !== undefined && !menuPageHeaderActions && (_jsx(Button, { type: "button", variant: iconOnlyMidActions ? "secondary" : "link", icon: linkCopied ? "check" : "link", testId: "render-edit-copy-link", ...(iconOnlyMidActions && {
771
- size: "icon",
772
- ariaLabel: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink"),
773
- }), onClick: async () => {
774
- await onCopyLink();
775
- setLinkCopied(true);
776
- }, children: iconOnlyMidActions
777
- ? null
778
- : 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 &&
779
- 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") }))] }));
780
- const headerSlot = screen.slots?.header;
781
- 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;
782
- // A function headerRegion places the slot itself (projectionDetail puts it
783
- // into its header card next to the actions); otherwise the slot renders
784
- // above the caller's own headerRegion.
785
- const formHeaderRegion = typeof headerRegion === "function" ? (headerRegion(headerSlotMount)) : headerSlotMount !== undefined ? (_jsxs(_Fragment, { children: [headerSlotMount, headerRegion] })) : (headerRegion);
786
- const titleActionSlot = screen.slots?.titleAction;
787
- const titleActionMount = titleActionSlot !== undefined ? (_jsx(EditSlotMount, { slot: titleActionSlot, slotName: "titleAction", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values })) : undefined;
788
- // A projectionDetail's synthesized entity is empty when every section is an
789
- // extension or relatedList; that is not a fieldless submit form, so it must not
790
- // draw a no-op Save. Extension registrations still get their Save, since
791
- // persistExtensions() runs on the customSubmit path too.
792
- const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
793
- const showsSubmit = !viewMode &&
794
- (isFormEditable ||
795
- hasExtensionRegistrations ||
796
- isFieldlessSubmitForm ||
797
- submitWithoutChanges !== undefined);
798
- const footerSlot = screen.slots?.footer;
45
+ draft.refs.saveDraftRef.current = draftActions.saveDraft;
46
+ const nav = createWizardNavigation(stepped, draftActions.saveDraft);
47
+ const base = { ...stepped, draftActions, nav };
48
+ const handleSubmit = createHandleSubmit(base);
49
+ draft.refs.handleSubmitRef.current = handleSubmit;
50
+ draft.refs.wizardNextRef.current = state.isWizard ? nav.handleWizardNext : () => { };
51
+ draft.refs.wizardBackRef.current = state.isWizard ? nav.handleWizardBack : () => { };
52
+ const engine = { ...base, handleSubmit };
53
+ const layout = deriveLayoutFlags(engine);
799
54
  // An unregistered footer component renders null, so only a resolvable one may
800
55
  // count as an action (else read-only screens get an empty footer strip).
801
- const FooterSlotComponent = useExtensionSectionComponent(footerSlot !== undefined ? extensionSectionName(footerSlot) : undefined);
802
- // Mirrors every branch inside formActions below — without this guard
803
- // DefaultForm renders an empty footer strip (border + padding, no content)
804
- // on read-only detail screens, since `actions` would otherwise always be
805
- // a defined (if empty) fragment.
806
- const showWizardBack = isWizard && currentStep > 0;
807
- const showWizardNext = isWizard && !isLastWizardStep;
808
- const showSubmit = showsSubmit && (!isWizard || isLastWizardStep);
809
- const hasFormActions = showWizardBack ||
810
- showWizardNext ||
811
- showSubmit ||
812
- FooterSlotComponent !== undefined ||
813
- writeFormFooterAction !== undefined;
814
- const nextStepTitle = isWizard ? filteredSections[currentStep + 1]?.title : undefined;
815
- const isCreate = (() => {
816
- const id = resolveExtensionEntityId(entityIdProp, vm.id);
817
- return id == null || id === "";
818
- })();
819
- const changedFieldCount = Object.keys(snapshot.changes).length;
820
- // Edit-mode screen forms always carry the dirty status (0 = quiet "No unsaved
821
- // changes"); wizards keep the status-only-when-dirty behaviour.
822
- const hasDirtyFooter = !isWizard &&
823
- !viewMode &&
824
- (dirtyFooter === true || (isScreenForm && !isCreate && headerRegion === undefined));
825
- const unsavedCount = !hasDirtyFooter
826
- ? undefined
827
- : changedFieldCount > 0
828
- ? changedFieldCount
829
- : extensionDirty
830
- ? 1
831
- : 0;
832
- const isSettingsList = screen.layout.variant === "settings-list" && hideSectionTitles !== true;
833
- const formActions = (_jsxs(_Fragment, { children: [showWizardBack && (_jsx(Button, { type: "button", variant: "secondary", icon: "chevron-left", onClick: handleWizardBack, testId: "render-edit-wizard-back", [FOOTER_ACTION_ROLE_PROP]: "back", children: translate("kumiko.actions.back") })), footerSlot !== undefined && (_jsx(EditSlotMount, { slot: footerSlot, slotName: "footer", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values, markSaved: markSaved, hasUnsavedChanges: hasUnsavedInput, wizardStep: isWizard ? { index: currentStep, isLast: isLastWizardStep } : undefined, ...(screen.slots?.footerPrimary === true && { [STICKY_PRIMARY_ACTION_PROP]: true }) })), isWizard && !isLastWizardStep && showsSubmit && !isCreateMode && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => void handleSubmit({ saveNow: true }), testId: "render-edit-wizard-save-close", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.wizard.save-and-close") })), showWizardNext && (_jsx(Button, { type: "submit", variant: "primary", iconEnd: "chevron-right", testId: "render-edit-wizard-next", [FOOTER_ACTION_ROLE_PROP]: "primary",
834
- [NARROW_LABEL_PROP]: translate("kumiko.actions.next"), children: nextStepTitle !== undefined
835
- ? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
836
- : translate("kumiko.actions.next") })), showSubmit &&
837
- footerActions?.map((action) => (_jsx(Button, { type: "button", variant: action.variant ?? "secondary", ...(action.icon !== undefined && { icon: action.icon }), disabled: isSubmitting || disabled, onClick: () => {
838
- controller.setValues(action.patch); // @cast-boundary form-values
839
- void handleSubmit();
840
- }, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), hasDirtyFooter && showsSubmit && unsavedCount !== undefined && unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => controller.reset(), testId: "render-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.form.discard") })), showSubmit && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged &&
841
- !extensionDirty &&
842
- !isFieldlessSubmitForm &&
843
- !insideDrawer &&
844
- submitWithoutChanges !== true) ||
845
- isSubmitting ||
846
- disabled ||
847
- submitBlocked, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
848
- (isWizard
849
- ? "kumiko.actions.finish"
850
- : hasDirtyFooter
851
- ? "kumiko.form.saveChanges"
852
- : "kumiko.actions.save")) })), writeFormFooterAction] }));
853
- // Title + Subtitle, create/edit-aware. i18n-Keys (mode = "create"|"edit"):
854
- // screen:<id>.<mode>.title / .<mode>.subtitle (mode = "create"|"edit"|"view")
855
- // Fallback chain: mode-specific → generic (screen:<id>.title/.subtitle).
856
- // title falls back to screenId; subtitle falls back to screen.description,
857
- // itself run through translate() so an i18n key (the established
858
- // convention for description, e.g. bundled-features) resolves instead of
859
- // showing the raw key — translate() returns the input unchanged for plain
860
- // prose, same as it does for an unknown key. Same slot
861
- // section.description already fills for a section. Falls back to
862
- // undefined (no subtitle) when neither is set.
863
- const formMode = isCreate ? "create" : viewMode ? "view" : "edit";
864
- const recordTitle = !isCreate && screen.recordTitleField !== undefined
865
- ? resolveRecordTitle(initial[screen.recordTitleField])
866
- : undefined;
867
- const resolveScreenText = (suffix) => {
868
- for (const key of [
869
- `screen:${i18nScreenId}.${formMode}.${suffix}`,
870
- `screen:${i18nScreenId}.${suffix}`,
871
- ]) {
872
- const value = translate(key);
873
- if (value !== key)
874
- return value;
875
- }
876
- return undefined;
56
+ const footerSlotComponent = useExtensionSectionComponent(footerSlotName(props.screen));
57
+ const footer = deriveFooterFlags(engine, layout, footerSlotComponent);
58
+ const titles = deriveTitles(engine, layout, footer);
59
+ const model = {
60
+ engine,
61
+ layout,
62
+ footer,
63
+ titles,
64
+ setFieldUntyped: form.controller.setField,
65
+ // @cast-boundary form-values: ExtensionSectionProps is not generic
66
+ // over TValues; controller is mount-lifetime-stable, see the onControlsReady effect in useRenderEditDraftSave.
67
+ patchForExtension: draft.patchAndScheduleDraftSave,
877
68
  };
878
- const plainFormTitle = resolveScreenText("title") ?? i18nScreenId;
879
- const formTitle = resolveTitleTemplate(screen.titleTemplate, snapshot.values, translate, plainFormTitle);
880
- // In tabs mode (hideSectionTitles) screen.description is dropped — the head
881
- // card carries it — but an explicit subtitle i18n override has no other
882
- // source there, so it stays unless it merely resolves to the description.
883
- const explicitSubtitle = resolveScreenText("subtitle");
884
- const descriptionSubtitle = screen.description !== undefined ? translate(screen.description) : undefined;
885
- const formSubtitle = hideSectionTitles !== true
886
- ? (explicitSubtitle ?? descriptionSubtitle)
887
- : explicitSubtitle !== descriptionSubtitle
888
- ? explicitSubtitle
889
- : undefined;
890
- // Embedded settings-list forms (dashboard panels) have no shell header: the
891
- // form title/subtitle move into the first section's header column instead of
892
- // floating as a heading above the band.
893
- const titleInSettingsHeader = isSettingsList && !titleInShell;
894
- const usesFieldFlow = isScreenForm || insideDrawer;
895
- // The caller's headerRegion already shows the record title in the shell page
896
- // header, so the type title ("Release") above the form would repeat it.
897
- const recordTitleInHeaderRegion = headerRegion !== undefined &&
898
- recordTitle !== undefined &&
899
- PageHeader !== undefined &&
900
- pageHeaderSlotAvailable;
901
- const allRequiredHint = (_jsx(Text, { variant: "muted", testId: "render-edit-all-required-hint", children: translate("kumiko.form.all-required") }));
902
- const stepRail = isWizard && isScreenForm && StepBar !== undefined;
903
- const upNextSection = screen.layout.wizard?.aside?.upNext === true ? filteredSections[currentStep + 1] : undefined;
904
- const sectionNavItems = isScreenForm && !isStepped
905
- ? filteredSections.flatMap((section, index) => section.kind !== "extension" &&
906
- section.kind !== "relatedList" &&
907
- section.kind !== "writeForm" &&
908
- section.visible &&
909
- section.title !== undefined &&
910
- section.title !== formTitle
911
- ? [{ id: formSectionDomId(index), title: section.title }]
912
- : [])
913
- : [];
914
- const compactStepLabel = (() => {
915
- const currentTitle = filteredSections[currentStep]?.title;
916
- return currentTitle !== undefined
917
- ? translate("kumiko.wizard.step-with-title", {
918
- current: currentStep + 1,
919
- total: lastStepIndex + 1,
920
- title: currentTitle,
921
- })
922
- : translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
923
- })();
924
- return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [menuActionConfirmDialog, titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
925
- pageHeaderCompact && { overflowItems: headerMenuItems }), ...(headerMenuItems.length > 0 &&
926
- !pageHeaderCompact &&
927
- ActionOverflowMenu !== undefined && {
928
- actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
929
- }) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(focusFirstErrorKey > 0 && { focusFirstErrorKey }), ...(hideSectionTitles !== true &&
930
- !titleInShell &&
931
- !titleInSettingsHeader &&
932
- !recordTitleInHeaderRegion && { title: formTitle }), ...(formSubtitle !== undefined &&
933
- !stepRail &&
934
- !titleInSettingsHeader && { subtitle: formSubtitle }), ...(isScreenForm && { screenForm: true }), ...(unsavedCount !== undefined &&
935
- (unsavedCount > 0 || hasDirtyFooter) && { unsavedCount }), ...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems }), ...(stepRail && {
936
- sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardStepSelect, ...updateModeStepBarProps, orientation: "vertical", heading: translate("kumiko.wizard.step", {
937
- current: currentStep + 1,
938
- total: lastStepIndex + 1,
939
- }), ...(formSubtitle !== undefined && { description: formSubtitle }), subtitles: filteredSections.map((section) => section.kind === "fields" ? section.subtitle : undefined), ...(upNextSection !== undefined && {
940
- upNext: {
941
- heading: translate("kumiko.wizard.up-next"),
942
- title: upNextSection.title ?? "",
943
- ...(upNextSection.kind === "fields" &&
944
- upNextSection.subtitle !== undefined && { subtitle: upNextSection.subtitle }),
945
- },
946
- }), testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" })),
947
- }), ...(hideActions !== true && hasFormActions && { actions: formActions }), ...(hideActions !== true &&
948
- hasSecondaryFormActions && { secondaryActions: secondaryFormActions }), testId: "render-edit-form", stickyActions: isWizard || fillScreenHeight === true, ...(screen.layout.width !== undefined && { width: screen.layout.width }), ...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion }), ...(titleActionMount !== undefined && { titleAction: titleActionMount }), ...(fillHeight && { fillHeight }), ...(hideSectionTitles === true && { chromeless: true }), ...(summary !== undefined && { summary }), children: _jsxs(AllFieldsRequiredProvider, { value: showsAllRequiredHint, children: [leadContent, showsAllRequiredHint && !insideDrawer && allRequiredHint, draftCandidates !== null && (_jsx(Banner, { variant: "info", testId: "render-edit-draft-picker", actions: [
949
- ...draftCandidates.map((candidate) => (_jsx(Button, { type: "button", variant: "link", onClick: () => adoptDraft(candidate), testId: `render-edit-draft-pick-${candidate.id}`, children: formatWhen(candidate.savedAt) }, candidate.id))),
950
- _jsx(Button, { type: "button", variant: "link", onClick: () => setDraftCandidates(null), testId: "render-edit-draft-start-new", children: translate("kumiko.form.draft.start-new") }, "start-new"),
951
- ], children: _jsx(Text, { children: translate(draftCandidates.length === 1
952
- ? "kumiko.form.draft.resume-single"
953
- : "kumiko.form.draft.resume-multiple") }) })), isWizard && !stepRail && (_jsxs(_Fragment, { children: [Progress !== undefined && (_jsx(Progress, { value: (currentStep + 1) / (lastStepIndex + 1), testId: "render-edit-wizard-progress" })), StepBar === undefined ? (_jsx(Text, { variant: "small", testId: "render-edit-wizard-step-label", children: compactStepLabel })) : (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardStepSelect, ...updateModeStepBarProps, testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" }))] })), isTabs &&
954
- isStepped &&
955
- (() => {
956
- // No Tabs primitive registered → every section renders stacked
957
- // (stepHidden below is gated on the same condition), which is the
958
- // single-layout behaviour. Degrading to one long form beats
959
- // hiding sections behind a strip that never drew.
960
- if (Tabs === undefined)
961
- return null;
962
- return (_jsx(Tabs, { testId: "render-edit-tabs", items: filteredSections.map((section, index) => ({
963
- id: tabIdAt(section, index),
964
- label: section.title ?? "",
965
- })), activeId: tabIdAt(filteredSections[currentStep], currentStep), onSelect: (id) => {
966
- const index = filteredSections.findIndex((s, i) => tabIdAt(s, i) === id);
967
- // skip: the strip reported an id no section owns.
968
- if (index === -1)
969
- return;
970
- jumpToStep(index);
971
- } }));
972
- })(), filteredSections.map((section, sectionIndex) => {
973
- // turns this section's own `actions` (RowAction[]) into
974
- // already-bound buttons via the caller-supplied builder, rendered
975
- // through the same RenderEditActionButton the top-level `actions`
976
- // prop uses (same style/confirm/icon/error handling).
977
- const sectionActions = section.actions;
978
- const sectionActionsEl = sectionActions !== undefined && sectionActions.length > 0
979
- ? buildSectionActions?.(sectionActions)?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id)))
980
- : undefined;
981
- // Wizard steps and tabs stay mounted while off-screen (native
982
- // `hidden`, not unmounted) so an extension section's submit-registry
983
- // entry (useExtensionFormSubmit → registry.remove on unmount)
984
- // survives navigating past its step — otherwise Finish only ran the
985
- // last mounted step's handler and silently dropped earlier steps'
986
- // writes. For tabs this is also what makes ONE submit cover every
987
- // tab, including tabs the user never opened.
988
- const stepHidden = isStepped && sectionIndex !== currentStep;
989
- const wrapWizardStep = (key, el) => {
990
- if (!isStepped)
991
- return el;
992
- if (WizardStepGroup === undefined) {
993
- throw new Error(`RenderEdit: ${screen.layout.mode} layout requires primitives.WizardStepGroup, but none is registered.`);
994
- }
995
- return (_jsx(WizardStepGroup, { hidden: stepHidden, ...(hideSectionTitles === true && { inset: true }), children: el }, key));
996
- };
997
- // Off-screen wizard steps stay mounted (see comment above) but must
998
- // not participate in native constraint validation, or the Next
999
- // button's `type="submit"` triggers the browser's full-form check
1000
- // — including required fields on unvisited steps, which are not
1001
- // focusable while hidden, so the wizard silently stops navigating
1002
- // (only a console warning, no visible error). A plain `hidden`
1003
- // attribute does NOT bar descendants from constraint validation on
1004
- // web — see WizardStepGroupProps for what implementations must
1005
- // guarantee.
1006
- if (section.kind === "extension") {
1007
- const mount = (_jsx(ExtensionSectionMount, { section: section, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), initialValues: extensionInitialValues, values: snapshot.values,
1008
- // @cast-boundary form-values: ExtensionSectionProps is not generic
1009
- // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
1010
- patch: patchAndScheduleDraftSave, validate: scopedValidate, markSaved: markSaved, hideTitle: hideSectionTitles, ...(isWizard &&
1011
- !isCreateMode && {
1012
- stepKey: wizardStepKey(section, sectionIndex),
1013
- onReportStepComplete: reportExtensionStepComplete,
1014
- }), ...(hideSectionTitles !== true &&
1015
- sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title));
1016
- // Tabs mode: an unframed padded panel (the tab panel is the
1017
- // surface), so extension content neither sits flush at the shell
1018
- // edge nor touches the tab strip. testId stays only on the inner
1019
- // Section — giving the wrapper the same one would register two
1020
- // elements under one id.
1021
- const wrapped = hideSectionTitles !== true ? (mount) : (_jsx(Card, { options: { framed: false }, ...(sectionActionsEl !== undefined && {
1022
- slots: { headerActions: sectionActionsEl },
1023
- }), children: mount }, section.title));
1024
- return wrapWizardStep(section.title, wrapped);
1025
- }
1026
- if (section.kind === "relatedList") {
1027
- // parentId is the displayed record's id — without it there's no
1028
- // parent row whose related rows could be queried.
1029
- // Rejected at boot in wizard layouts, so no WizardStepGroup here.
1030
- const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
1031
- if (parentId === null)
1032
- return null;
1033
- const mount = (_jsx(RelatedListSection, { section: section, parentId: parentId,
1034
- // @cast-boundary form-values: TValues is structurally a Record.
1035
- record: snapshot.values, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, ...(refreshToken !== undefined && { refreshToken }), ...(fillScreenHeight === true && { grow: true }), ...(hideSectionTitles !== true &&
1036
- sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(onRelatedListDrawerAction !== undefined && {
1037
- onOpenDrawer: onRelatedListDrawerAction,
1038
- }) }, section.title));
1039
- // Tabs mode: the table runs full-bleed under the tab strip (board
1040
- // layout); the toolbar buttons live in the list's own toolbar.
1041
- return hideSectionTitles === true ? (_jsx("div", { className: fillScreenHeight === true ? "flex min-h-0 flex-1 flex-col" : undefined, children: mount }, section.title)) : (mount);
1042
- }
1043
- if (section.kind === "writeForm") {
1044
- // Own submit button + dispatcher call, entirely independent of
1045
- // this screen's (nonexistent, on projectionDetail) form submit —
1046
- // rejected at boot in wizard layouts, so no WizardStepGroup here.
1047
- return (_jsx(WriteFormSection, { section: section, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, onSubmitted: () => onReload?.(), ...(isLoneWriteFormTab && { onFooterAction: setWriteFormFooterAction }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title ?? `write-form-${sectionIndex}`));
1048
- }
1049
- if (!section.visible)
1050
- return null;
1051
- // Title-less sections would otherwise collide on key/testId, so use the index
1052
- // as a fallback.
1053
- const sectionKey = section.title ?? `section-${sectionIndex}`;
1054
- const renderFieldGrid = (fields, columns, gridKey, columnsDeclared) => (_jsx(Grid, { columns: usesFieldFlow && columnsDeclared !== true ? 1 : columns, ...(isSettingsList ? { list: true } : usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(!isSettingsList && usesFieldFlow && { flow: true }), ...(isSettingsList && {
1055
- layout: "row",
1056
- accent: fieldAccent?.(field.field) === true,
1057
- }), issues: snapshot.errors[field.field], onChange: (v) => {
1058
- controller.setField(field.field, v);
1059
- clearErrorsOnInput(field.field);
1060
- if (validateOnChange === true)
1061
- controller.validate([field.field]);
1062
- }, GridCell: GridCell, featureName: featureName, ...(labelAppendix !== undefined && {
1063
- labelAppendix: labelAppendix(field.field),
1064
- }), ...(fieldAppendix !== undefined && {
1065
- fieldAppendix: fieldAppendix(field.field),
1066
- }), ...(fieldAppendixPlacement !== undefined && {
1067
- appendixPlacement: fieldAppendixPlacement,
1068
- }), ...(fieldDescription !== undefined && {
1069
- description: fieldDescription(field.field),
1070
- }), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...(hideSectionTitles !== true &&
1071
- section.title !== undefined && { sectionTitle: section.title }), ...((!isCreate || markChangedFields) &&
1072
- field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
1073
- // Tabs mode: the tab panel is the surface, so field sections render
1074
- // as unframed padded panels; group cards inside stay framed.
1075
- // Stays `Card`, not `Section` — Section always flattens to a
1076
- // borderless divider when rendered inside this component's own
1077
- // `<Form>` (InsideFormContext), even with `chromeless` set, so it
1078
- // cannot stand in for the tabs-mode card here.
1079
- if (hideSectionTitles === true) {
1080
- if (section.groups !== undefined) {
1081
- const groupsGrid = (_jsx(Grid, { columns: 1, children: section.groups.map((group, groupIndex) => (_jsx(Card, { slots: { title: group.title }, testId: `section-${sectionKey}-group-${groupIndex}`, children: renderFieldGrid(group.fields, group.columns, `${sectionKey}-group-${groupIndex}-grid`, group.columnsDeclared) }, group.title))) }, `${sectionKey}-groups`));
1082
- // `actions` on a `groups` section has no group of its own to
1083
- // sit in and would need an extra title-less Card around the
1084
- // group cards (Card-in-Card) — the boot-validator rejects that
1085
- // combination in tabs mode, so `sectionActionsEl` is always
1086
- // undefined here at runtime.
1087
- return wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, children: groupsGrid }, sectionKey));
1088
- }
1089
- // No section title here — the Tab strip right above
1090
- // already names this panel, so a repeated Card title would just
1091
- // duplicate it. The header row only exists to carry the section
1092
- // description and actions, never the title text.
1093
- const cardEl = (_jsx(Card, { options: { framed: false }, ...((sectionActionsEl !== undefined || section.description !== undefined) && {
1094
- slots: {
1095
- ...(section.description !== undefined && { subtitle: section.description }),
1096
- ...(sectionActionsEl !== undefined && { headerActions: sectionActionsEl }),
1097
- },
1098
- }), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`, section.columnsDeclared) }, sectionKey));
1099
- return wrapWizardStep(sectionKey, cardEl);
1100
- }
1101
- // Suppress the section header when it would just repeat the form
1102
- // title verbatim (typical for single-section actionForms), or the
1103
- // tab label right above it. Without a Tabs primitive the sections
1104
- // render stacked, and then the titles are what tells them apart.
1105
- const repeatsTabLabel = isTabs && isStepped;
1106
- const headerFallback = titleInSettingsHeader && sectionIndex === 0;
1107
- const sectionTitle = headerFallback
1108
- ? (section.title ?? formTitle)
1109
- : section.title === formTitle || repeatsTabLabel
1110
- ? undefined
1111
- : section.title;
1112
- const sectionSubtitle = section.description ?? (headerFallback ? formSubtitle : undefined);
1113
- const sectionEl = (_jsxs(Section, { ...(sectionNavItems.some((item) => item.id === formSectionDomId(sectionIndex)) && {
1114
- id: formSectionDomId(sectionIndex),
1115
- }), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(sectionSubtitle !== undefined && { subtitle: sectionSubtitle }), ...(section.icon !== undefined && { icon: section.icon }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(isSettingsList && { layout: "settings-list" }), testId: `section-${sectionKey}`, children: [showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint, renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`, section.columnsDeclared)] }, sectionKey));
1116
- return wrapWizardStep(sectionKey, sectionEl);
1117
- }), formError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
1118
- onReload();
1119
- setFormError(null);
1120
- setExtraDeleteIssues([]);
1121
- }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), rootIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-root", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`))), extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })), formError === null &&
1122
- rootIssues.map((issue) => (_jsx(Banner, { variant: "error", testId: "render-edit-root-issue", children: _jsx(Text, { children: translate(issue.i18nKey, issue.params) }) }, issue.i18nKey))), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
1123
- const rejection = await onDelete();
1124
- // A rejected delete (e.g. a preDelete hook) carries its reason in
1125
- // the field issues; the top-level key is only "validation failed".
1126
- if (isDispatcherRejection(rejection)) {
1127
- const [issue, ...otherIssues] = rejection.details?.fields ?? [];
1128
- setExtraDeleteIssues(otherIssues);
1129
- setFormErrorSource("delete");
1130
- setFormError(issue === undefined
1131
- ? rejection
1132
- : { ...rejection, i18nKey: issue.i18nKey, i18nParams: issue.params });
1133
- }
1134
- }, testId: "render-edit-delete-dialog" }))] }) })] }));
69
+ return renderEditLayout(model);
1135
70
  }