@cosmicdrift/kumiko-renderer 0.328.0 → 0.328.1

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 (259) hide show
  1. package/dist/app/action-form-shim.d.ts +14 -0
  2. package/{src/app/action-form-shim.ts → dist/app/action-form-shim.js} +26 -46
  3. package/dist/app/app-features-context.d.ts +8 -0
  4. package/dist/app/app-features-context.js +16 -0
  5. package/dist/app/column-renderers.d.ts +30 -0
  6. package/dist/app/column-renderers.js +34 -0
  7. package/dist/app/config-edit-shim.d.ts +11 -0
  8. package/{src/app/config-edit-shim.ts → dist/app/config-edit-shim.js} +12 -25
  9. package/dist/app/content-editors.d.ts +38 -0
  10. package/dist/app/content-editors.js +26 -0
  11. package/dist/app/content-preview.d.ts +11 -0
  12. package/dist/app/content-preview.js +30 -0
  13. package/dist/app/custom-screens.d.ts +11 -0
  14. package/dist/app/custom-screens.js +20 -0
  15. package/dist/app/dashboard-body.d.ts +16 -0
  16. package/dist/app/dashboard-body.js +11 -0
  17. package/dist/app/extension-form-submit.d.ts +32 -0
  18. package/dist/app/extension-form-submit.js +73 -0
  19. package/dist/app/extension-sections.d.ts +72 -0
  20. package/dist/app/extension-sections.js +34 -0
  21. package/dist/app/feature-schema.d.ts +4 -0
  22. package/{src/app/feature-schema.ts → dist/app/feature-schema.js} +12 -22
  23. package/dist/app/form-schema.d.ts +4 -0
  24. package/dist/app/form-schema.js +85 -0
  25. package/dist/app/kumiko-screen.d.ts +43 -0
  26. package/dist/app/kumiko-screen.js +2206 -0
  27. package/dist/app/layout-fields.d.ts +3 -0
  28. package/dist/app/layout-fields.js +20 -0
  29. package/dist/app/list-facets.d.ts +48 -0
  30. package/dist/app/list-facets.js +185 -0
  31. package/dist/app/nav.d.ts +61 -0
  32. package/dist/app/nav.js +139 -0
  33. package/dist/app/projection-detail-shim.d.ts +8 -0
  34. package/dist/app/projection-detail-shim.js +71 -0
  35. package/dist/app/projection-list-shim.d.ts +13 -0
  36. package/{src/app/projection-list-shim.ts → dist/app/projection-list-shim.js} +26 -41
  37. package/dist/app/qn.d.ts +7 -0
  38. package/{src/app/qn.ts → dist/app/qn.js} +9 -11
  39. package/dist/app/qualify-screen-id.d.ts +2 -0
  40. package/{src/app/qualify-screen-id.ts → dist/app/qualify-screen-id.js} +5 -6
  41. package/dist/app/reference-facet-bridge.d.ts +10 -0
  42. package/dist/app/reference-facet-bridge.js +22 -0
  43. package/dist/app/return-to.d.ts +31 -0
  44. package/dist/app/return-to.js +188 -0
  45. package/dist/app/row-actions.d.ts +77 -0
  46. package/dist/app/row-actions.js +416 -0
  47. package/dist/app/screen-access.d.ts +8 -0
  48. package/dist/app/screen-access.js +48 -0
  49. package/dist/app/secret-mint-body.d.ts +10 -0
  50. package/dist/app/secret-mint-body.js +140 -0
  51. package/dist/app/secrets-edit-body.d.ts +8 -0
  52. package/dist/app/secrets-edit-body.js +76 -0
  53. package/dist/app/use-embedded-screen.d.ts +6 -0
  54. package/dist/app/use-embedded-screen.js +19 -0
  55. package/dist/app/variable-chips.d.ts +7 -0
  56. package/dist/app/variable-chips.js +16 -0
  57. package/dist/app/write-failed-error.d.ts +6 -0
  58. package/dist/app/write-failed-error.js +21 -0
  59. package/dist/components/embedded-list-field.d.ts +10 -0
  60. package/dist/components/embedded-list-field.js +259 -0
  61. package/dist/components/grid-cell-for-field.d.ts +25 -0
  62. package/dist/components/grid-cell-for-field.js +31 -0
  63. package/dist/components/multi-select-checkboxes.d.ts +14 -0
  64. package/dist/components/multi-select-checkboxes.js +36 -0
  65. package/dist/components/reference-create-dialog.d.ts +13 -0
  66. package/dist/components/reference-create-dialog.js +46 -0
  67. package/dist/components/related-list-section.d.ts +25 -0
  68. package/dist/components/related-list-section.js +273 -0
  69. package/dist/components/render-edit-action-button.d.ts +22 -0
  70. package/dist/components/render-edit-action-button.js +43 -0
  71. package/dist/components/render-edit-logic.d.ts +6 -0
  72. package/dist/components/render-edit-logic.js +63 -0
  73. package/dist/components/render-edit-types.d.ts +209 -0
  74. package/dist/components/render-edit-types.js +1 -0
  75. package/dist/components/render-edit.d.ts +6 -0
  76. package/dist/components/render-edit.js +1169 -0
  77. package/dist/components/render-field.d.ts +38 -0
  78. package/dist/components/render-field.js +544 -0
  79. package/dist/components/render-list.d.ts +117 -0
  80. package/dist/components/render-list.js +232 -0
  81. package/dist/components/write-form-section.d.ts +17 -0
  82. package/dist/components/write-form-section.js +104 -0
  83. package/dist/context/dispatcher-context.d.ts +10 -0
  84. package/{src/context/dispatcher-context.tsx → dist/context/dispatcher-context.js} +16 -29
  85. package/dist/context/draft-storage-context.d.ts +12 -0
  86. package/dist/context/draft-storage-context.js +18 -0
  87. package/dist/context/user-roles-context.d.ts +7 -0
  88. package/{src/context/user-roles-context.tsx → dist/context/user-roles-context.js} +7 -15
  89. package/dist/format-when.d.ts +1 -0
  90. package/{src/format-when.ts → dist/format-when.js} +7 -7
  91. package/dist/hooks/reference-limits.d.ts +3 -0
  92. package/{src/hooks/reference-limits.ts → dist/hooks/reference-limits.js} +0 -1
  93. package/dist/hooks/use-ai-text.d.ts +52 -0
  94. package/dist/hooks/use-ai-text.js +90 -0
  95. package/dist/hooks/use-disclosure.d.ts +7 -0
  96. package/dist/hooks/use-disclosure.js +8 -0
  97. package/dist/hooks/use-form.d.ts +7 -0
  98. package/dist/hooks/use-form.js +36 -0
  99. package/dist/hooks/use-list-url-state.d.ts +43 -0
  100. package/dist/hooks/use-list-url-state.js +131 -0
  101. package/dist/hooks/use-mutation.d.ts +9 -0
  102. package/dist/hooks/use-mutation.js +37 -0
  103. package/dist/hooks/use-query.d.ts +14 -0
  104. package/dist/hooks/use-query.js +118 -0
  105. package/dist/hooks/use-reference-lookup.d.ts +14 -0
  106. package/dist/hooks/use-reference-lookup.js +63 -0
  107. package/dist/hooks/use-store.d.ts +3 -0
  108. package/dist/hooks/use-store.js +38 -0
  109. package/dist/hooks/use-stream-handler.d.ts +14 -0
  110. package/dist/hooks/use-stream-handler.js +93 -0
  111. package/dist/i18n-defaults.d.ts +2 -0
  112. package/dist/i18n-defaults.js +201 -0
  113. package/dist/i18n.d.ts +64 -0
  114. package/dist/i18n.js +166 -0
  115. package/dist/index.d.ts +77 -0
  116. package/dist/index.js +55 -0
  117. package/dist/inside-drawer.d.ts +2 -0
  118. package/{src/inside-drawer.ts → dist/inside-drawer.js} +2 -5
  119. package/dist/page-header-slot.d.ts +2 -0
  120. package/{src/page-header-slot.ts → dist/page-header-slot.js} +2 -5
  121. package/dist/primitives.d.ts +1351 -0
  122. package/dist/primitives.js +73 -0
  123. package/dist/screen-fills-height.d.ts +3 -0
  124. package/dist/screen-fills-height.js +3 -0
  125. package/dist/share-links.d.ts +4 -0
  126. package/dist/share-links.js +11 -0
  127. package/dist/sort-by-accessor.d.ts +5 -0
  128. package/dist/sort-by-accessor.js +16 -0
  129. package/dist/sse/live-events.d.ts +23 -0
  130. package/dist/sse/live-events.js +28 -0
  131. package/dist/tokens.d.ts +83 -0
  132. package/dist/tokens.js +80 -0
  133. package/package.json +16 -9
  134. package/src/__tests__/error-i18n-defaults.test.ts +0 -82
  135. package/src/__tests__/format-when.test.ts +0 -12
  136. package/src/__tests__/i18n.test.tsx +0 -283
  137. package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
  138. package/src/__tests__/qn.test.ts +0 -83
  139. package/src/__tests__/share-links.test.ts +0 -26
  140. package/src/__tests__/sort-by-accessor.test.ts +0 -48
  141. package/src/__tests__/stringify-nav-params.test.ts +0 -33
  142. package/src/__tests__/use-list-url-state.test.tsx +0 -250
  143. package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
  144. package/src/app/__tests__/action-form-shim.test.ts +0 -137
  145. package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
  146. package/src/app/__tests__/config-edit-shim.test.ts +0 -57
  147. package/src/app/__tests__/content-editors.test.tsx +0 -128
  148. package/src/app/__tests__/content-preview.test.tsx +0 -184
  149. package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
  150. package/src/app/__tests__/entity-edit-fill-height.test.tsx +0 -118
  151. package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +0 -239
  152. package/src/app/__tests__/entity-list-create-singleton.test.tsx +0 -128
  153. package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +0 -424
  154. package/src/app/__tests__/entity-list-row-action-icon.test.tsx +0 -248
  155. package/src/app/__tests__/entity-list-row-action-kumiko-actions-view.test.tsx +0 -162
  156. package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +0 -401
  157. package/src/app/__tests__/entity-list-screen-padding.test.tsx +0 -128
  158. package/src/app/__tests__/feature-schema.test.ts +0 -40
  159. package/src/app/__tests__/form-schema.test.ts +0 -247
  160. package/src/app/__tests__/list-daterange-facet.test.ts +0 -165
  161. package/src/app/__tests__/list-filter-facets.test.tsx +0 -521
  162. package/src/app/__tests__/nav.test.ts +0 -198
  163. package/src/app/__tests__/projection-detail-actions.test.tsx +0 -719
  164. package/src/app/__tests__/projection-detail-reference-field.test.tsx +0 -165
  165. package/src/app/__tests__/projection-detail-shim.test.ts +0 -188
  166. package/src/app/__tests__/projection-detail-singleton.test.tsx +0 -191
  167. package/src/app/__tests__/projection-list-bad-shape.test.tsx +0 -152
  168. package/src/app/__tests__/projection-list-reference-column.test.tsx +0 -219
  169. package/src/app/__tests__/projection-list-search-sort.test.tsx +0 -212
  170. package/src/app/__tests__/qualified-names.test.ts +0 -37
  171. package/src/app/__tests__/return-to.test.ts +0 -396
  172. package/src/app/__tests__/row-actions-navigate-tab.test.ts +0 -223
  173. package/src/app/__tests__/row-default-edit-action.test.tsx +0 -324
  174. package/src/app/__tests__/screen-access-allows.test.ts +0 -34
  175. package/src/app/__tests__/secret-mint-body.test.tsx +0 -404
  176. package/src/app/__tests__/variable-chips.test.tsx +0 -70
  177. package/src/app/app-features-context.tsx +0 -25
  178. package/src/app/column-renderers.tsx +0 -68
  179. package/src/app/content-editors.tsx +0 -85
  180. package/src/app/content-preview.tsx +0 -67
  181. package/src/app/custom-screens.tsx +0 -29
  182. package/src/app/dashboard-body.tsx +0 -33
  183. package/src/app/extension-form-submit.tsx +0 -123
  184. package/src/app/extension-sections.tsx +0 -111
  185. package/src/app/form-schema.ts +0 -91
  186. package/src/app/kumiko-screen.tsx +0 -3801
  187. package/src/app/layout-fields.ts +0 -31
  188. package/src/app/list-facets.ts +0 -259
  189. package/src/app/nav.tsx +0 -258
  190. package/src/app/projection-detail-shim.ts +0 -86
  191. package/src/app/reference-facet-bridge.tsx +0 -58
  192. package/src/app/return-to.tsx +0 -234
  193. package/src/app/row-actions.ts +0 -640
  194. package/src/app/screen-access.ts +0 -63
  195. package/src/app/secret-mint-body.tsx +0 -259
  196. package/src/app/secrets-edit-body.tsx +0 -194
  197. package/src/app/use-embedded-screen.ts +0 -27
  198. package/src/app/variable-chips.tsx +0 -39
  199. package/src/app/write-failed-error.ts +0 -27
  200. package/src/components/__tests__/embedded-list-field.test.tsx +0 -531
  201. package/src/components/__tests__/multi-select-checkboxes.test.tsx +0 -162
  202. package/src/components/__tests__/related-list-section-empty-state.test.tsx +0 -143
  203. package/src/components/__tests__/related-list-section.test.tsx +0 -1733
  204. package/src/components/__tests__/render-edit-action-button.test.tsx +0 -205
  205. package/src/components/__tests__/render-edit-hidden-section.test.tsx +0 -128
  206. package/src/components/__tests__/render-edit-logic.test.ts +0 -314
  207. package/src/components/__tests__/render-edit-screen-description.test.tsx +0 -152
  208. package/src/components/__tests__/render-edit-submit-actions.test.tsx +0 -460
  209. package/src/components/__tests__/render-field-app-locale.test.tsx +0 -142
  210. package/src/components/__tests__/render-field-enum-option.test.tsx +0 -99
  211. package/src/components/__tests__/render-field-icon-derivation.test.tsx +0 -119
  212. package/src/components/__tests__/render-field-json-format.test.tsx +0 -164
  213. package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +0 -212
  214. package/src/components/__tests__/render-field-money-roundtrip.test.tsx +0 -217
  215. package/src/components/__tests__/render-field-number-grouping.test.tsx +0 -76
  216. package/src/components/__tests__/render-field-number-unit.test.tsx +0 -227
  217. package/src/components/__tests__/render-field-password-format.test.tsx +0 -101
  218. package/src/components/__tests__/render-field-select-display.test.tsx +0 -134
  219. package/src/components/__tests__/render-field-unit-format.test.tsx +0 -107
  220. package/src/components/__tests__/render-field-unsupported-types.test.tsx +0 -195
  221. package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +0 -146
  222. package/src/components/__tests__/write-form-section.test.tsx +0 -375
  223. package/src/components/embedded-list-field.tsx +0 -354
  224. package/src/components/grid-cell-for-field.tsx +0 -88
  225. package/src/components/multi-select-checkboxes.tsx +0 -93
  226. package/src/components/reference-create-dialog.tsx +0 -91
  227. package/src/components/related-list-section.tsx +0 -491
  228. package/src/components/render-edit-action-button.tsx +0 -109
  229. package/src/components/render-edit-logic.ts +0 -85
  230. package/src/components/render-edit-types.ts +0 -226
  231. package/src/components/render-edit.tsx +0 -1871
  232. package/src/components/render-field.tsx +0 -951
  233. package/src/components/render-list.tsx +0 -593
  234. package/src/components/write-form-section.tsx +0 -198
  235. package/src/context/draft-storage-context.tsx +0 -39
  236. package/src/hooks/__tests__/use-ai-text.test.tsx +0 -177
  237. package/src/hooks/__tests__/use-disclosure.test.tsx +0 -25
  238. package/src/hooks/__tests__/use-form.test.tsx +0 -65
  239. package/src/hooks/__tests__/use-mutation.test.tsx +0 -77
  240. package/src/hooks/__tests__/use-query.test.tsx +0 -149
  241. package/src/hooks/__tests__/use-stream-handler.test.tsx +0 -107
  242. package/src/hooks/use-ai-text.ts +0 -172
  243. package/src/hooks/use-disclosure.ts +0 -20
  244. package/src/hooks/use-form.ts +0 -78
  245. package/src/hooks/use-list-url-state.ts +0 -207
  246. package/src/hooks/use-mutation.ts +0 -61
  247. package/src/hooks/use-query.ts +0 -166
  248. package/src/hooks/use-reference-lookup.ts +0 -76
  249. package/src/hooks/use-store.ts +0 -48
  250. package/src/hooks/use-stream-handler.ts +0 -134
  251. package/src/i18n-defaults.ts +0 -240
  252. package/src/i18n.tsx +0 -286
  253. package/src/index.ts +0 -279
  254. package/src/primitives.tsx +0 -1567
  255. package/src/screen-fills-height.ts +0 -3
  256. package/src/share-links.ts +0 -12
  257. package/src/sort-by-accessor.ts +0 -20
  258. package/src/sse/live-events.tsx +0 -56
  259. package/src/tokens.tsx +0 -170
@@ -0,0 +1,1169 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
4
+ import { RenderEditActionButton } from "./render-edit-action-button.js";
5
+ import { AllFieldsRequiredProvider } from "./render-field.js";
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
8
+ import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
9
+ import { useOptionalDispatcher } from "../context/dispatcher-context.js";
10
+ import { useDraftStorage } from "../context/draft-storage-context.js";
11
+ import { formatWhen } from "../format-when.js";
12
+ import { useForm } from "../hooks/use-form.js";
13
+ import { useTranslation } from "../i18n.js";
14
+ import { useInsideDrawer } from "../inside-drawer.js";
15
+ import { usePageHeaderSlotAvailable } from "../page-header-slot.js";
16
+ import { STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
17
+ import { GridCellForField } from "./grid-cell-for-field.js";
18
+ import { RelatedListSection } from "./related-list-section.js";
19
+ import { filterEditSections, hasEditableSection, hasIssueWithoutRenderedField, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
20
+ import { WriteFormSection } from "./write-form-section.js";
21
+ // Qualified names of the bundled `form-draft` feature. Hardcoded because the
22
+ // renderer must not depend on @cosmicdrift/kumiko-bundled-features; a screen
23
+ // with `layout.draft: true` and that feature unmounted is a boot error, so
24
+ // these can never dangle silently.
25
+ const FORM_DRAFT_GET = "form-draft:query:get";
26
+ const FORM_DRAFT_SAVE = "form-draft:write:save";
27
+ const FORM_DRAFT_DISCARD = "form-draft:write:discard";
28
+ const FORM_DRAFT_LIST = "form-draft:query:list";
29
+ // Mirrors bundled-features/src/form-draft/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
30
+ // — same hardcoding rationale as the QNs above (no dependency on that package).
31
+ const FORM_DRAFT_KEY_MAX_LENGTH = 256;
32
+ // Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
33
+ // patch() call (VIN-decode, an extension section) should not save immediately
34
+ // per call, but patch() can also fire from inside onChange on every keystroke
35
+ // (the #1888 derived-field shape) — 500ms collapses a typing burst into one
36
+ // save instead of one per keystroke, while still saving well before a user
37
+ // abandons the tab.
38
+ export const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
39
+ // draftKey convention for a create-mode draft (framework-wizard-mode.md,
40
+ // lookup.ts): `${screenId}:new:${draftId}`. Shared by the mount-time list
41
+ // fallback (strip the prefix to recover a candidate's draftId) and the
42
+ // list query itself (filter out edit-mode drafts, which share the same
43
+ // `${screenId}:%` LIKE-prefix scan server-side).
44
+ function newDraftPrefix(screenId) {
45
+ return `${screenId}:new:`;
46
+ }
47
+ // `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
48
+ // without a polyfill — guard like dispatcher-live.ts's generateRequestId.
49
+ function mintDraftId() {
50
+ const c = globalThis.crypto;
51
+ if (typeof c?.randomUUID === "function")
52
+ return c.randomUUID();
53
+ return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
54
+ }
55
+ // End-to-end renderer für einen entityEdit screen. Rendert aus-
56
+ // schließlich über Primitives — kein raw HTML. Ein Native-Renderer
57
+ // der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
58
+ // Änderungen.
59
+ function toConditionValue(cond) {
60
+ if (typeof cond === "boolean")
61
+ return cond;
62
+ // @cast-boundary form-values: TValues ist strukturell ein Record.
63
+ return (values) => evalFieldCondition(cond, values);
64
+ }
65
+ // Tab-strip key. The boot-validator makes `id` mandatory on a tabs layout and
66
+ // entityEdit only ever has "fields"/"extension" sections (relatedList and
67
+ // writeForm are rejected outright), so the positional fallback is unreachable
68
+ // for a tabs screen — it only keeps this total for other layouts.
69
+ function tabIdAt(section, index) {
70
+ const declared = section?.kind === "fields" || section?.kind === "extension" ? section.id : undefined;
71
+ return declared ?? `section-${index}`;
72
+ }
73
+ function deriveFormFields(screen) {
74
+ const out = {};
75
+ for (const section of screen.layout.sections) {
76
+ // relatedList carries no `fields` for the form-condition map either.
77
+ if (!isFieldsEditSection(section))
78
+ continue;
79
+ for (const spec of section.fields) {
80
+ const normalized = normalizeEditField(spec);
81
+ out[normalized.field] = {
82
+ ...(normalized.visible !== undefined && {
83
+ visible: toConditionValue(normalized.visible),
84
+ }),
85
+ ...(normalized.readOnly !== undefined && {
86
+ readonly: toConditionValue(normalized.readOnly),
87
+ }),
88
+ ...(normalized.required !== undefined && {
89
+ required: toConditionValue(normalized.required),
90
+ }),
91
+ };
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ // Resolves an extension-section's `{ react: { __component: "X" } }` marker
97
+ // to a registered React component via ExtensionSectionsProvider (filled in
98
+ // createKumikoApp from clientFeatures.extensionSectionComponents) and
99
+ // mounts it with the host entity name + id. Hook lives in its own
100
+ // component so we don't call `use*` inside vm.sections.map (rules-of-
101
+ // hooks would punish reordering sections between renders).
102
+ function ExtensionSectionMount({ section, entityName: hostEntityName, entityId, initialValues, values, patch, validate, hideTitle, actions, }) {
103
+ const { Banner, FillContainer, Section, Text } = usePrimitives();
104
+ const name = extensionSectionName(section.component);
105
+ const Component = useExtensionSectionComponent(name);
106
+ const testId = `section-extension-${section.title}`;
107
+ if (Component === undefined) {
108
+ const placeholder = (_jsx(Banner, { variant: "info", testId: `section-extension-placeholder-${section.title}`, children: _jsxs(Text, { children: ["Extension section component ", _jsx(Text, { variant: "code", children: name ?? "(no __component name)" }), " ", "not registered in clientFeatures.extensionSectionComponents."] }) }));
109
+ // Tabs mode already frames this in the caller's own Card — a Section
110
+ // here would add a second, unwanted divider inset (px-6 py-4) on top of
111
+ // the Card's own padding, misaligning this tab's content against the
112
+ // fields tab (Grid sits directly in the Card there). FillContainer keeps
113
+ // the testId without adding chrome (same primitive RelatedListSection
114
+ // uses for its own hideTitle branch).
115
+ return hideTitle === true ? (FillContainer !== undefined ? (_jsx(FillContainer, { testId: testId, children: placeholder })) : (placeholder)) : (_jsx(Section, { title: section.title, testId: testId, children: placeholder }, section.title));
116
+ }
117
+ const mounted = (_jsx(Component, { entityName: section.entityName ?? hostEntityName, entityId: entityId, initialValues: initialValues, values: values, patch: patch, validate: validate }));
118
+ // Same reasoning as the placeholder branch above: tabs mode's outer Card
119
+ // is the only frame this content gets, no nested Section divider.
120
+ return hideTitle === true ? (FillContainer !== undefined ? (_jsx(FillContainer, { testId: testId, children: mounted })) : (mounted)) : (_jsx(Section, { ...(actions !== undefined && { actions }), title: section.title, testId: testId, children: mounted }));
121
+ }
122
+ // Resolves an entityEdit slot's (`screen.slots.header` / `.footer`)
123
+ // `{ react: { __component: "X" } }` marker via the same ExtensionSectionsProvider
124
+ // registry as ExtensionSectionMount / ListHeaderSlotMount and mounts it. One
125
+ // component for both slots — header passes no hasUnsavedChanges/wizardStep,
126
+ // footer does. Own component for rules-of-hooks (useExtensionSectionComponent
127
+ // must not run conditionally inside RenderEdit's render body).
128
+ function EditSlotMount({ slot, slotName, screenId, entityName, entityId, values, hasUnsavedChanges, wizardStep, }) {
129
+ const name = extensionSectionName(slot);
130
+ const Component = useExtensionSectionComponent(name);
131
+ useEffect(() => {
132
+ if (name !== undefined && Component === undefined) {
133
+ // biome-ignore lint/suspicious/noConsole: dev warning for a setup error
134
+ console.warn(`[kumiko] Edit ${slotName} slot component "${name}" on screen "${screenId}" is not registered in clientFeatures.extensionSectionComponents — the ${slotName} slot renders nothing.`);
135
+ }
136
+ }, [name, Component, screenId, slotName]);
137
+ if (Component === undefined)
138
+ return null;
139
+ return (_jsx(Component, { entityName: entityName, entityId: entityId, values: values, ...(hasUnsavedChanges !== undefined && { hasUnsavedChanges }), ...(wizardStep !== undefined && { wizardStep }) }));
140
+ }
141
+ function formSectionDomId(sectionIndex) {
142
+ return `form-section-${sectionIndex}`;
143
+ }
144
+ export function RenderEdit(props) {
145
+ const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, summary, labelAppendix, fieldAppendix, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, headerRegion, buildSectionActions, } = props;
146
+ const { customSubmit } = props;
147
+ // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
148
+ // konsumieren wir den i18next-Context direkt. Sonst wären Field-
149
+ // Labels ohne Caller-Wiring raw-Keys (`feature:entity:foo:field:title`).
150
+ // useTranslation throwt ohne LocaleProvider — das ist ok, weil RenderEdit
151
+ // ohnehin nur in einem mounted Kumiko-App-Tree läuft.
152
+ const t = useTranslation();
153
+ const translate = translateProp ?? t;
154
+ const dispatcher = useOptionalDispatcher();
155
+ const isWizard = screen.layout.mode === "wizard";
156
+ // Tabs reuse the wizard's step machinery: one section visible at a time,
157
+ // all of them mounted, `currentStep` as the active index. What they do NOT
158
+ // share is navigation — a tab form submits from any tab, so every
159
+ // step-transition path below stays wizard-only (fw#3134).
160
+ const isTabs = screen.layout.mode === "tabs";
161
+ const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
162
+ const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
163
+ const draftStorage = useDraftStorage();
164
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
165
+ const [linkCopied, setLinkCopied] = useState(false);
166
+ const [isSubmitting, setIsSubmitting] = useState(false);
167
+ const [formError, setFormError] = useState(null);
168
+ // One state for all header actions (actions?), not per-button — only one
169
+ // action can be in flight at a time, and this keeps the error surfaced in
170
+ // the shared formError-adjacent banner region instead of inside the
171
+ // button row (fw#2166 review: a full-width Banner there breaks the
172
+ // flex justify-end action bar).
173
+ const [actionError, setActionError] = useState(null);
174
+ const [rawStep, setRawStep] = useState(0);
175
+ // Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
176
+ // on mount so a same-tab reload finds the right one of several parallel
177
+ // create-sessions on this screen; `null` until the first step change
178
+ // mints one (saveDraft), or the mount-time `form-draft:query:list`
179
+ // fallback below adopts an existing one. Edit-mode never touches this —
180
+ // its draftKey is `${screen.id}:${entityIdProp}` unconditionally.
181
+ const [draftId, setDraftId] = useState(() => isCreateMode ? draftStorage.getDraftId(screen.id) : null);
182
+ // Marks a draftId this instance minted itself (saveDraft, first step
183
+ // change) so the restore effect below doesn't immediately re-fetch what
184
+ // it just wrote — there is nothing to restore, the row is brand new.
185
+ const mintedDraftIdRef = useRef(null);
186
+ // Marks that the mount-time `form-draft:query:list` fallback already ran
187
+ // once for this mount. Without this, a create-mode discard resets `draftId`
188
+ // to `null` (see discardDraft below) and would re-arm the list effect —
189
+ // silently adopting an unrelated parallel draft on the same screen right
190
+ // after the user just submitted (#1908).
191
+ const didListRef = useRef(false);
192
+ const [draftCandidates, setDraftCandidates] = useState(null);
193
+ // Composed-Save: Extension-Sections melden hier ihren dirty-State (damit der
194
+ // Save-Button aktiv wird wenn NUR eine Section geändert wurde) + ihren
195
+ // Submit-Handler (läuft nach dem Entity-Write). extensionErrorKey hält den
196
+ // i18n-Key einer fehlgeschlagenen Section-Persistierung.
197
+ //
198
+ // Not a second write path for saveDraft()'s data (#1914): deriveFormFields
199
+ // (above) skips extension sections entirely, so extension-owned field state
200
+ // never enters `fields`/`controller.getSnapshot().values` — there is no
201
+ // draft-blob-covered state left for persistExtensions() to compete over.
202
+ const [extensionDirty, setExtensionDirty] = useState(false);
203
+ const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
204
+ const [extensionErrorKey, setExtensionErrorKey] = useState(null);
205
+ const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(({ anyDirty, hasRegistrations }) => {
206
+ setExtensionDirty(anyDirty);
207
+ setHasExtensionRegistrations(hasRegistrations);
208
+ });
209
+ const { Button, Banner, Card, Dialog, Form, Section, Grid, GridCell, Text, Progress, StepBar, WizardStepGroup, Tabs, PageHeader, ActionOverflowMenu, } = usePrimitives();
210
+ const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
211
+ const insideDrawer = useInsideDrawer();
212
+ // Both stepped layouts show one section at a time and keep the rest mounted
213
+ // but inert. A tabs layout on a host without the Tabs primitive has no strip
214
+ // to switch with, so it falls back to rendering every section stacked rather
215
+ // than stranding the user on section 0 (fw#3134).
216
+ const isStepped = isWizard || (isTabs && Tabs !== undefined);
217
+ const fields = useMemo(() => deriveFormFields(screen), [screen]);
218
+ // Must be computed before submitConfig/useForm bakes it in (the controller
219
+ // freezes it on first render). This is the unfiltered field-name set from
220
+ // `fieldsFilter` ∩ `fields` — not the value-dependent `filteredSections`
221
+ // (which also drops `section.visible === false`). Hidden fields still fall
222
+ // out via form-controller's `hiddenFields` path. undefined = unscoped, since
223
+ // scoping would silently drop root-level .refine() issues.
224
+ // A `fieldsFilter` that matches nothing in `fields` (typo, renamed field)
225
+ // must fall back to unscoped too — an empty scope array would filter out
226
+ // ALL validation issues (kumiko-framework#1907), letting submit() through
227
+ // unvalidated on a form that also renders no sections.
228
+ const scopeFieldNames = useMemo(() => {
229
+ if (fieldsFilter === undefined)
230
+ return undefined;
231
+ const matched = fieldsFilter.filter((f) => Object.hasOwn(fields, f));
232
+ return matched.length === 0 ? undefined : matched;
233
+ }, [fieldsFilter, fields]);
234
+ // Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
235
+ // customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
236
+ // weil wir controller.submit() eh nicht rufen.
237
+ const submitConfig = writeCommand !== undefined
238
+ ? {
239
+ type: writeCommand,
240
+ payloadMode,
241
+ ...(buildPayload !== undefined && { buildPayload }),
242
+ ...(serverFieldPathPrefix !== undefined && { serverFieldPathPrefix }),
243
+ ...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
244
+ }
245
+ : undefined;
246
+ const { controller, snapshot } = useForm({
247
+ initial,
248
+ fields,
249
+ ...(schema !== undefined && { schema }),
250
+ ...(ctx !== undefined && { ctx }),
251
+ ...(submitConfig !== undefined && { submit: submitConfig }),
252
+ });
253
+ // Derived from the screen id + the host entity id only — never from
254
+ // `vm.id`, which lives in the form values a restore mutates (load under one
255
+ // key, save under another). Edit-mode: `${screen.id}:${entityIdProp}`,
256
+ // unchanged. Create-mode: `${screen.id}:new:${draftId}` once a draftId
257
+ // exists (resumed, adopted, or minted), `undefined` before that — two
258
+ // parallel create sessions on the same screen must never collapse onto
259
+ // the same key (kumiko-framework#1908).
260
+ const draftKey = useMemo(() => isCreateMode
261
+ ? draftId !== null
262
+ ? `${screen.id}:new:${draftId}`
263
+ : undefined
264
+ : `${screen.id}:${entityIdProp}`, [screen.id, entityIdProp, isCreateMode, draftId]);
265
+ // Controlled mode (Issue #1887). Both callbacks live in refs so a
266
+ // re-rendering caller (identity-unstable `onChange`/`onControlsReady`
267
+ // closures, e.g. inline arrows) doesn't retrigger these effects — only a
268
+ // real snapshot mutation (typing, patch()) does. Without this, a caller
269
+ // whose onChange calls patch() to fill other fields (the VIN-decode use
270
+ // case from #1888) would loop: patch → new snapshot → effect
271
+ // re-subscribes with a "new" onChange → refires.
272
+ const onChangeRef = useRef(onChange);
273
+ onChangeRef.current = onChange;
274
+ // Guards against redelivering to the SAME callback identity on every
275
+ // render (an inline-arrow onControlsReady would otherwise refire the
276
+ // effect below on every render since the callback itself is now a dep).
277
+ // Tracks the actual prop, not a ref snapshot, so a caller that swaps in a
278
+ // real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
279
+ // gets delivered to for THIS mount instead of never (fw#1899).
280
+ const deliveredControlsToRef = useRef(undefined);
281
+ const onControlsReadyRef = useRef(onControlsReady);
282
+ onControlsReadyRef.current = onControlsReady;
283
+ const hasNavigatedRef = useRef(false);
284
+ const submittedRef = useRef(false);
285
+ const isSubmittingRef = useRef(false);
286
+ const scopeFieldNamesRef = useRef(scopeFieldNames);
287
+ scopeFieldNamesRef.current = scopeFieldNames;
288
+ const scopedValidate = useCallback(() => controller.validate(scopeFieldNamesRef.current), [controller]);
289
+ // `saveDraft` (defined below) is a fresh closure every render, over that
290
+ // render's `draftKey`/`draftId` — the ref lets the debounce timer below
291
+ // always call the CURRENT one even though the timer was scheduled by an
292
+ // earlier render's patch() call. `currentStepRef` gives it the current
293
+ // wizard step without depending on `currentStep` (computed further down,
294
+ // from `filteredSections`) at the point patchAndScheduleDraftSave itself
295
+ // is defined — kept fresh where `currentStep` is computed below.
296
+ const saveDraftRef = useRef(saveDraft);
297
+ saveDraftRef.current = saveDraft;
298
+ // `handleSubmit` (defined below) is a fresh closure over this render's
299
+ // snapshot/extensionDirty, while the onControlsReady effect fires once per
300
+ // mount — the ref lets controls.submit() always reach the current one.
301
+ const handleSubmitRef = useRef(async () => { });
302
+ const currentStepRef = useRef(0);
303
+ const draftSaveTimerRef = useRef(null);
304
+ useEffect(() => {
305
+ return () => {
306
+ if (draftSaveTimerRef.current !== null) {
307
+ clearTimeout(draftSaveTimerRef.current);
308
+ // Flush the pending debounced save instead of dropping it — a
309
+ // navigation/unmount inside the debounce window would otherwise
310
+ // silently discard the last patch() (discardDraft nulls this ref
311
+ // first on the post-submit path, so this can't resurrect a
312
+ // just-submitted draft).
313
+ saveDraftRef.current(currentStepRef.current);
314
+ }
315
+ };
316
+ }, []);
317
+ // patch() (controlled mode, extension sections) applies immediately —
318
+ // only the resulting draft save is debounced (#1914), so a patch() burst
319
+ // (VIN-decode filling several fields, or onChange fanning a keystroke out
320
+ // through patch(), see the #1888 test above) collapses into one save.
321
+ const patchAndScheduleDraftSave = useCallback((partial) => {
322
+ controller.setValues(partial);
323
+ if (!draftEnabled || dispatcher === undefined || disabled || submittedRef.current)
324
+ return;
325
+ if (draftSaveTimerRef.current !== null)
326
+ clearTimeout(draftSaveTimerRef.current);
327
+ draftSaveTimerRef.current = setTimeout(() => {
328
+ draftSaveTimerRef.current = null;
329
+ if (disabled || submittedRef.current)
330
+ return;
331
+ saveDraftRef.current(currentStepRef.current);
332
+ }, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
333
+ }, [controller, draftEnabled, dispatcher, disabled]);
334
+ // Runs before the onChange effect below (declaration order = React
335
+ // execution order) so that an onChange fired on mount always sees
336
+ // `controls !== undefined` — both effects depend only on mount-stable
337
+ // values (controller/scopedValidate/patchAndScheduleDraftSave), so the
338
+ // swap is otherwise a no-op. See the #1888 VIN-decode test: an initial
339
+ // value that should derive dependent fields on mount needs controls.patch
340
+ // available on the very first onChange call, not just from the second
341
+ // keystroke onward.
342
+ const hasControlsReady = onControlsReady !== undefined;
343
+ useEffect(() => {
344
+ if (!hasControlsReady)
345
+ return;
346
+ const ready = onControlsReadyRef.current;
347
+ if (ready === undefined)
348
+ return;
349
+ if (deliveredControlsToRef.current === ready)
350
+ return;
351
+ deliveredControlsToRef.current = ready;
352
+ ready({
353
+ patch: patchAndScheduleDraftSave,
354
+ validate: scopedValidate,
355
+ getValues: () => controller.getSnapshot().values,
356
+ submit: () => handleSubmitRef.current(),
357
+ });
358
+ // Dep on boolean presence only — inline-arrow identity must not redeliver.
359
+ }, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
360
+ // `schema` is typically an inline caller prop (`schema={z.object({...})}`)
361
+ // with unstable identity across renders — as an effect dep it would refire
362
+ // on every parent render, not just on a snapshot change, risking a loop if
363
+ // the caller's onChange triggers a parent re-render. Held in a ref like
364
+ // onChangeRef so only a real snapshot mutation retriggers this effect.
365
+ const schemaRef = useRef(schema);
366
+ schemaRef.current = schema;
367
+ // Controls are guaranteed ready by the time this fires (see the
368
+ // onControlsReady effect above, which is declared first on purpose).
369
+ useEffect(() => {
370
+ const cb = onChangeRef.current;
371
+ if (cb === undefined)
372
+ return;
373
+ // Dry-run parse against `schema` — NOT controller.validate(). Calling
374
+ // validate() here would write field-level errors into snapshot.errors
375
+ // on every keystroke, painting error messages while the user is still
376
+ // typing. `valid` can therefore legitimately diverge from what's
377
+ // currently rendered under the fields (the last *mutating* validate()
378
+ // call, e.g. from controls.validate() or submit()).
379
+ const currentSchema = schemaRef.current;
380
+ const valid = currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
381
+ cb({
382
+ values: snapshot.values,
383
+ changes: snapshot.changes,
384
+ dirty: snapshot.isDirty,
385
+ valid,
386
+ submitting: isSubmitting,
387
+ });
388
+ }, [snapshot, isSubmitting]);
389
+ const onDirtyChangeRef = useRef(onDirtyChange);
390
+ onDirtyChangeRef.current = onDirtyChange;
391
+ const hasUnsavedInput = snapshot.isDirty || extensionDirty;
392
+ useEffect(() => {
393
+ onDirtyChangeRef.current?.(hasUnsavedInput);
394
+ }, [hasUnsavedInput]);
395
+ useEffect(() => {
396
+ // skip: this screen does not persist a draft.
397
+ if (!draftEnabled || dispatcher === undefined)
398
+ return;
399
+ // skip: create-mode with no draftId yet — nothing to restore, either
400
+ // the list-fallback effect below finds one or the first step change
401
+ // mints a fresh one.
402
+ if (draftKey === undefined)
403
+ return;
404
+ // skip: this draftId was just minted by saveDraft — the row it wrote
405
+ // is exactly the current form state, re-fetching it would be a no-op
406
+ // round-trip at best and a stale-echo clobber at worst.
407
+ if (draftId !== null && draftId === mintedDraftIdRef.current)
408
+ return;
409
+ let cancelled = false;
410
+ void (async () => {
411
+ try {
412
+ const result = await dispatcher.query(FORM_DRAFT_GET, { draftKey });
413
+ // skip: superseded by a newer mount, or the draft lookup failed — an
414
+ // unreachable draft store must not break the form itself.
415
+ if (cancelled || !result.isSuccess)
416
+ return;
417
+ const draft = result.data?.draft ?? null;
418
+ // skip: nothing saved yet for this key.
419
+ if (draft === null)
420
+ return;
421
+ // skip: the user already typed or wizard-navigated while the lookup
422
+ // was in flight — their input wins over the stored draft.
423
+ if (controller.getSnapshot().isDirty || hasNavigatedRef.current)
424
+ return;
425
+ // @cast-boundary form-draft blob: `values` round-trips through an opaque
426
+ // jsonb column and comes back untyped.
427
+ controller.setValues(draft.values);
428
+ setRawStep(Math.max(draft.stepIndex, 0));
429
+ }
430
+ catch (err) {
431
+ // biome-ignore lint/suspicious/noConsole: draft restore must not red-screen the form
432
+ console.warn("render-edit: draft restore query failed", err);
433
+ }
434
+ })();
435
+ return () => {
436
+ cancelled = true;
437
+ };
438
+ }, [draftEnabled, draftKey, draftId, dispatcher, controller]);
439
+ // Mount-time fallback (issue #1913) for create-mode when no draftId
440
+ // survived in storage (new tab, cleared storage): ask the server for this
441
+ // screen's open drafts and let the user choose via a picker (below) —
442
+ // even for exactly one candidate. Auto-adopting a lone candidate would
443
+ // silently hand this tab someone else's in-progress draft from a parallel
444
+ // session on the same screen (cross-tab hijack); the picker's "start new"
445
+ // action covers the common case (it really was this tab's own draft) with
446
+ // one extra click. Zero candidates → stay null, saveDraft mints a fresh
447
+ // draftId on the first step change same as any other fresh create.
448
+ useEffect(() => {
449
+ // skip: this screen does not persist a draft, this is edit-mode, a
450
+ // draftId is already known (from storage or an earlier adoption), or
451
+ // this mount already ran the list lookup once (didListRef).
452
+ if (!draftEnabled ||
453
+ !isCreateMode ||
454
+ draftId !== null ||
455
+ didListRef.current ||
456
+ dispatcher === undefined)
457
+ return;
458
+ let cancelled = false;
459
+ void (async () => {
460
+ try {
461
+ const result = await dispatcher.query(FORM_DRAFT_LIST, { screenId: screen.id });
462
+ // skip: superseded by a newer mount, or the lookup failed — an
463
+ // unreachable draft store must not break a fresh create.
464
+ if (cancelled || !result.isSuccess)
465
+ return;
466
+ didListRef.current = true;
467
+ const prefix = newDraftPrefix(screen.id);
468
+ // list's LIKE-prefix scan also matches edit-mode drafts
469
+ // (`${screenId}:${entityId}`) — keep only create-mode ones.
470
+ const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
471
+ // skip: no open drafts for this screen — stay null, saveDraft mints a
472
+ // fresh draftId on the first step change same as any other create.
473
+ if (candidates.length === 0)
474
+ return;
475
+ setDraftCandidates(candidates);
476
+ }
477
+ catch (err) {
478
+ // biome-ignore lint/suspicious/noConsole: draft lookup must not red-screen the form
479
+ console.warn("render-edit: draft list query failed", err);
480
+ }
481
+ })();
482
+ return () => {
483
+ cancelled = true;
484
+ };
485
+ }, [draftEnabled, isCreateMode, draftId, dispatcher, screen.id]);
486
+ // User picked one of several open drafts from the mount-time picker
487
+ // (see draftCandidates below) — adopt it the same way a single
488
+ // auto-adopted candidate would be.
489
+ function adoptDraft(candidate) {
490
+ // Locked state (#1896/fw#1909): `disabled` means "no write possible" —
491
+ // repointing draftKey and patching in the candidate's values is exactly
492
+ // that, so a locked form must not adopt a draft.
493
+ if (disabled)
494
+ return;
495
+ const adoptedId = candidate.draftKey.slice(newDraftPrefix(screen.id).length);
496
+ draftStorage.setDraftId(screen.id, adoptedId);
497
+ setDraftId(adoptedId);
498
+ setDraftCandidates(null);
499
+ }
500
+ const vm = useMemo(() => computeEditViewModel({
501
+ screen,
502
+ entity,
503
+ values: snapshot.values,
504
+ translate,
505
+ featureName,
506
+ }), [screen, entity, snapshot.values, translate, featureName]);
507
+ const filteredSections = useMemo(
508
+ // A fully-hidden "fields" section (every field in it currently
509
+ // condition-hidden) must not occupy a wizard step; it would render
510
+ // empty and block Back/Next on nothing. Extension, relatedList and
511
+ // writeForm sections carry no `visible` (they own their own lifecycle /
512
+ // run their own query / own submit), so they always pass through. A
513
+ // section with no fields at all (e.g. a review-only step) has
514
+ // `visible: fields.some(...)` = false vacuously; that's "no fields to
515
+ // hide", not "hidden", so it stays too (fw#1901).
516
+ () => filterEditSections(vm.sections, fieldsFilter).filter((section) => section.kind === "extension" ||
517
+ section.kind === "relatedList" ||
518
+ section.kind === "writeForm" ||
519
+ section.fields.length === 0 ||
520
+ section.visible), [vm.sections, fieldsFilter]);
521
+ const allFieldsRequired = useMemo(() => {
522
+ const fillable = filteredSections.flatMap((section) => section.kind === "fields"
523
+ ? section.fields.filter((field) => field.visible && !field.readOnly)
524
+ : []);
525
+ return fillable.length > 0 && fillable.every((field) => field.required);
526
+ }, [filteredSections]);
527
+ const showsAllRequiredHint = allFieldsRequired && hideSectionTitles !== true && !isWizard;
528
+ // true when editable fields exist or an extension opted into composed submit (fw#2359).
529
+ const isFormEditable = hasEditableSection(filteredSections);
530
+ // A fieldless form (input-less secretMint mint, fw#2838 — the secret is
531
+ // server-generated, the mint step is only its submit button) has no
532
+ // section to gate on: it IS the action, so it always shows the submit
533
+ // button, and it can never go dirty either.
534
+ const isFieldless = Object.keys(entity.fields).length === 0;
535
+ // A lone relatedList tab (`hideSectionTitles` is only ever set by
536
+ // ProjectionDetailBody, which hands this component ONE section — its
537
+ // synthesized screen drops layout.mode, so an entityEdit tabs layout never
538
+ // reaches this line and `filteredSections[0]` stays the active section)
539
+ // needs its own tab panel capped at the available height so its table
540
+ // scrolls inside the panel instead of the whole page stretching to the
541
+ // row count (fw#2722) — a short table still sizes to its content instead
542
+ // of stretching the panel to the bottom (fw#2778). Any other layout —
543
+ // multiple sections, a non-relatedList tab, stacked (non-tabs) forms —
544
+ // keeps normal document-flow height untouched.
545
+ const fillHeight = fillScreenHeight === true ||
546
+ (hideSectionTitles === true && filteredSections[0]?.kind === "relatedList");
547
+ // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
548
+ // false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
549
+ // Entity-Kontext (create-mode ohne route-id) gibt es nichts zu schreiben.
550
+ async function persistExtensions() {
551
+ const entityId = resolveExtensionEntityId(entityIdProp, vm.id);
552
+ if (entityId === null)
553
+ return true;
554
+ const results = await runExtensionSubmits({ entityId });
555
+ const failed = results.find((r) => !r.isSuccess);
556
+ if (failed !== undefined) {
557
+ setExtensionErrorKey(failed.errorKey ?? "kumiko.form.extension.save-failed");
558
+ return false;
559
+ }
560
+ return true;
561
+ }
562
+ const lastStepIndex = Math.max(filteredSections.length - 1, 0);
563
+ // Clamped on read, not on write: section visibility is value-dependent, so a
564
+ // stored stepIndex can point past what is currently rendered — that lands on
565
+ // the last step instead of an empty one.
566
+ const currentStep = Math.min(rawStep, lastStepIndex);
567
+ // Kept fresh for patchAndScheduleDraftSave's debounce timer above, which
568
+ // is defined before `currentStep` exists (it depends on `filteredSections`,
569
+ // computed further up from `vm`) and so cannot close over it directly.
570
+ currentStepRef.current = currentStep;
571
+ const isLastWizardStep = currentStep >= lastStepIndex;
572
+ // Step transitions only — never per keystroke. Deliberately not awaited: a
573
+ // failed draft save must not block the step change.
574
+ //
575
+ // Create-mode, first draft save (step change OR a debounced patch() from
576
+ // controlled mode / extension sections, see patchAndScheduleDraftSave
577
+ // above — both call this on step 0 too): mints the draftId here (issue
578
+ // #1913) rather than at mount, so a form nobody ever interacted with
579
+ // never claims a draftId or writes a row. `draftKey`/`draftId` state
580
+ // won't reflect the mint until the next render, so the just-minted key is
581
+ // computed inline instead of read from the memoized `draftKey`.
582
+ function saveDraft(stepIndex) {
583
+ // skip: this screen does not persist a draft.
584
+ if (!draftEnabled || dispatcher === undefined)
585
+ return;
586
+ let key = draftKey;
587
+ if (isCreateMode && draftId === null) {
588
+ const mintedId = mintDraftId();
589
+ mintedDraftIdRef.current = mintedId;
590
+ draftStorage.setDraftId(screen.id, mintedId);
591
+ setDraftId(mintedId);
592
+ key = `${screen.id}:new:${mintedId}`;
593
+ // A stale picker from the mount-time list fallback must not survive a
594
+ // mint — picking a candidate afterwards would repoint draftKey at an
595
+ // unrelated draft mid-edit and overwrite it (#1908).
596
+ setDraftCandidates(null);
597
+ }
598
+ // skip: create-mode with a draftId that failed to mint — unreachable
599
+ // in practice (mint above always produces one), kept as a type-level
600
+ // guard against a stale `undefined` key ever reaching the write.
601
+ if (key === undefined)
602
+ return;
603
+ // draftKeySchema.max(FORM_DRAFT_KEY_MAX_LENGTH) rejects this server-side —
604
+ // catch it here instead of losing the save silently to the fire-and-forget
605
+ // write below (the user would only notice on resume, with nothing to resume).
606
+ if (key.length > FORM_DRAFT_KEY_MAX_LENGTH) {
607
+ // biome-ignore lint/suspicious/noConsole: no error-surfacing path exists for a fire-and-forget draft save.
608
+ console.warn(`RenderEdit: draftKey "${key}" is ${key.length} chars, over the server's ${FORM_DRAFT_KEY_MAX_LENGTH}-char limit — skipping this draft save. Shorten screen.id or entity id.`);
609
+ return;
610
+ }
611
+ const passwordFieldNames = new Set(vm.sections.flatMap((section) => section.kind === "fields"
612
+ ? section.fields.filter((f) => f.format === "password").map((f) => f.field)
613
+ : []));
614
+ // A password field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
615
+ const draftValues = Object.fromEntries(Object.entries(controller.getSnapshot().values).filter(([field]) => !passwordFieldNames.has(field)));
616
+ void dispatcher
617
+ .write(FORM_DRAFT_SAVE, {
618
+ draftKey: key,
619
+ values: draftValues,
620
+ stepIndex,
621
+ })
622
+ .catch((err) => {
623
+ // biome-ignore lint/suspicious/noConsole: fire-and-forget draft save must not red-screen
624
+ console.warn("render-edit: draft save failed", err);
625
+ });
626
+ }
627
+ // Next: scoped validate() on the current step's fields — errors stay
628
+ // attached to the field and block the step transition. Extension steps
629
+ // have no field scope here (they validate via the controlled-mode
630
+ // controls.validate() API), so no call, no clearing of unrelated errors.
631
+ function handleWizardNext() {
632
+ const next = Math.min(currentStep + 1, lastStepIndex);
633
+ hasNavigatedRef.current = true;
634
+ // Locked state (#1896): `disabled` means "no input/no write", not "no
635
+ // navigation" — a disabled wizard must still be steppable so Back/Next
636
+ // reach every step. But neither validate() (would block on an empty
637
+ // required field nobody can fill in) nor saveDraft() (would mint a
638
+ // draftId / dispatch a write — exactly what `disabled` forbids) may run.
639
+ if (disabled) {
640
+ setRawStep(next);
641
+ return;
642
+ }
643
+ const section = filteredSections[currentStep];
644
+ const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
645
+ // skip: the current step has field errors — no transition, no draft save.
646
+ if (fieldNames.length > 0 && !controller.validate(fieldNames))
647
+ return;
648
+ setRawStep(next);
649
+ saveDraft(next);
650
+ }
651
+ function handleWizardBack() {
652
+ handleWizardJumpBack(currentStep - 1);
653
+ }
654
+ // Only backwards: a forward jump would skip the per-step validate() gate.
655
+ function handleWizardJumpBack(targetStep) {
656
+ if (targetStep >= currentStep)
657
+ return;
658
+ const previous = Math.max(targetStep, 0);
659
+ hasNavigatedRef.current = true;
660
+ if (disabled) {
661
+ setRawStep(previous);
662
+ return;
663
+ }
664
+ setRawStep(previous);
665
+ saveDraft(previous);
666
+ }
667
+ async function discardDraft() {
668
+ // skip: this screen does not persist a draft.
669
+ if (!draftEnabled || dispatcher === undefined)
670
+ return;
671
+ // A pending debounced patch-save must not fire after discard — it would
672
+ // resurrect the draft it just deleted.
673
+ if (draftSaveTimerRef.current !== null) {
674
+ clearTimeout(draftSaveTimerRef.current);
675
+ draftSaveTimerRef.current = null;
676
+ }
677
+ // skip: create-mode, no step change happened yet — no draftId was ever
678
+ // minted, so no row exists to discard.
679
+ if (draftKey === undefined)
680
+ return;
681
+ // Best-effort: a rejected write (e.g. network error) must not propagate
682
+ // out of handleSubmit AFTER the entity write already succeeded — that
683
+ // would break onSubmit/navigation/toast and risk a duplicate submit on
684
+ // retry. An orphaned draft row is swept later by cleanup.job.ts. Local
685
+ // cleanup below still runs on failure so this instance doesn't try to
686
+ // resume a draft it already considers submitted.
687
+ try {
688
+ await dispatcher.write(FORM_DRAFT_DISCARD, { draftKey });
689
+ }
690
+ catch {
691
+ // best-effort, see comment above.
692
+ }
693
+ // A successful submit ends this draftId's life — a subsequent create on
694
+ // the same screen (new mount) must mint its own, not resume this one.
695
+ if (isCreateMode) {
696
+ draftStorage.clearDraftId(screen.id);
697
+ mintedDraftIdRef.current = null;
698
+ setDraftId(null);
699
+ }
700
+ }
701
+ // Stepped modes (wizard, tabs): locates the first step whose "fields"
702
+ // section contains one of the given field paths — matched on the path's
703
+ // first segment, since `FieldIssue.path`/`snapshot.errors` keys can be
704
+ // dotted (embedded-list rows, e.g. `tasks.2.title`) while a section's field
705
+ // name is always the plain top-level name. Returns undefined for a
706
+ // root-level issue (path `"(root)"`, see zod-bridge.ts) or one that matches
707
+ // no rendered field — callers must NOT suppress the error banner in that
708
+ // case, there is nowhere to jump the user to. Extension sections are not
709
+ // jump targets by design: they validate through the controlled-mode
710
+ // controls.validate() API and own no field specs to match against.
711
+ function findFirstErroringStep(fieldPaths) {
712
+ const erroredFields = new Set(fieldPaths.map((p) => p.split(".")[0]));
713
+ const idx = filteredSections.findIndex((s) => s.kind === "fields" && s.fields.some((f) => erroredFields.has(f.field)));
714
+ return idx === -1 ? undefined : idx;
715
+ }
716
+ async function handleSubmit(options) {
717
+ // Enter in the active step triggers the native form submit (Next is
718
+ // type="submit" for Enter support) — on intermediate steps that means
719
+ // "Next", not "Save". Checked BEFORE the `disabled` guard below:
720
+ // `disabled` means "no input/no write", not "no navigation" — a
721
+ // disabled wizard must still be steppable (handleWizardNext itself
722
+ // blocks validate()/saveDraft() while disabled). `saveNow` is the explicit
723
+ // "Save and close" button and skips the step advance.
724
+ if (isWizard && !isLastWizardStep && options?.saveNow !== true) {
725
+ handleWizardNext();
726
+ return;
727
+ }
728
+ // Locked state (#1896): the submit button is visibly disabled, but a
729
+ // native form submit (Enter key) reaches this handler regardless of the
730
+ // button's disabled attribute — block it here too, not just in the UI.
731
+ if (disabled)
732
+ return;
733
+ // Sync guard — React state `isSubmitting` is too late for double-clicks
734
+ // in the same tick (customSubmit has no submitInFlight of its own).
735
+ if (isSubmittingRef.current)
736
+ return;
737
+ isSubmittingRef.current = true;
738
+ setIsSubmitting(true);
739
+ setExtensionErrorKey(null);
740
+ try {
741
+ // Extension-only: only a section is dirty, the main form is unchanged.
742
+ // No entity write (would send an empty changes payload) — only
743
+ // die Section-Handler laufen lassen.
744
+ if (snapshot.isUnchanged && extensionDirty) {
745
+ // Same discard as the main path: an extension-only save is still a
746
+ // successful submit, so the draft must not survive it.
747
+ if (await persistExtensions()) {
748
+ submittedRef.current = true;
749
+ await discardDraft();
750
+ }
751
+ return;
752
+ }
753
+ let result;
754
+ if (customSubmit !== undefined) {
755
+ // customSubmit path (e.g. configEdit, which fires a separate write
756
+ // per field). Client-side validation first, then
757
+ // an den Caller; on-success rebased der Form-State explizit
758
+ // because controller.submit() normally does this itself and
759
+ // without customSubmit's help the controller knows nothing about
760
+ // erfolgreichen Submit (isUnchanged blieb sonst false).
761
+ //
762
+ // WICHTIG: snapshot direkt vom Controller holen statt aus
763
+ // React state. On rapid fill→click, React batching may not have
764
+ // committed input state updates yet when the submit click fires.
765
+ // handleSubmit's closure would then run with
766
+ // stale snapshot.changes={} laufen, customSubmit fired keine
767
+ // Writes, returnt success, Form rebase → User glaubt "saved"
768
+ // aber gar nichts ist passiert. controller.getSnapshot() ist
769
+ // immer aktuell — der Controller ist die Source-of-Truth, die
770
+ // React state is only a mirror for rendering.
771
+ const valid = controller.validate(scopeFieldNames);
772
+ if (!valid) {
773
+ // Field order matters: validationBlocked:true is its own
774
+ // variant in the SubmitResult union (NOT mixed with data/error);
775
+ // TS only narrows cleanly without a discriminator fight.
776
+ const blocked = {
777
+ validationBlocked: true,
778
+ isSuccess: false,
779
+ };
780
+ result = blocked;
781
+ }
782
+ else {
783
+ result = await customSubmit(controller.getSnapshot());
784
+ if (result.isSuccess)
785
+ controller.rebase();
786
+ }
787
+ }
788
+ else {
789
+ result = await controller.submit();
790
+ }
791
+ // Form-level errors (without field-level details) go to the banner.
792
+ // Field errors flow via snapshot.errors into the individual fields.
793
+ let extensionsPersisted = true;
794
+ if (result.isSuccess) {
795
+ setFormError(null);
796
+ // `isNoOp: true` (payloadMode "changes", pre-filled form submitted
797
+ // untouched) means controller.submit() never called dispatcher.write
798
+ // — nothing to discard, and discarding here would delete a draft the
799
+ // form never persisted (fw#1978). Awaited, not fire-and-forget:
800
+ // `onSubmit` typically navigates away and unmounts this form, which
801
+ // would abort an in-flight discard and leave the draft behind after
802
+ // a successful submit.
803
+ if (result.isNoOp !== true) {
804
+ submittedRef.current = true;
805
+ await discardDraft();
806
+ }
807
+ extensionsPersisted = await persistExtensions();
808
+ }
809
+ else if (result.validationBlocked) {
810
+ // Root-level `.refine()`/cross-field issues from controller.validate()
811
+ // — same "jump to the erroring step" treatment as a server field
812
+ // error below, sourced from the freshly-validated snapshot.
813
+ if (isStepped) {
814
+ const step = findFirstErroringStep(Object.keys(controller.getSnapshot().errors));
815
+ if (step !== undefined)
816
+ setRawStep(step);
817
+ }
818
+ }
819
+ else {
820
+ const fieldIssues = result.error.details?.fields ?? [];
821
+ const issuePaths = fieldIssues.map((i) => i.path);
822
+ // A server field error on a step the form isn't currently showing is
823
+ // otherwise invisible — jump to the first step that contains one of
824
+ // the errored fields. For tabs this is what keeps the submit from
825
+ // blocking in silence, the failure mode the boot-validator used to
826
+ // prevent by rejecting the layout outright (fw#3134). An issue no
827
+ // rendered field can show (`version`, `id`, a root-level refine, a
828
+ // hidden field) keeps the banner, jump or not.
829
+ if (isStepped) {
830
+ const step = findFirstErroringStep(issuePaths);
831
+ if (step !== undefined)
832
+ setRawStep(step);
833
+ }
834
+ setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections) ? result.error : null);
835
+ }
836
+ // skip: on entity-success-but-extension-failure the extension-error
837
+ // banner is showing — don't notify (the caller navigates away on success
838
+ // and would unmount it before the user sees the failure).
839
+ if (shouldNotifyCaller(result, extensionsPersisted))
840
+ onSubmit?.(result);
841
+ }
842
+ finally {
843
+ isSubmittingRef.current = false;
844
+ setIsSubmitting(false);
845
+ }
846
+ }
847
+ handleSubmitRef.current = handleSubmit;
848
+ // Screen forms (entityEdit/actionForm filling the shell height) drop the card;
849
+ // the title then lives in the shell header when the shell offers a slot.
850
+ const isScreenForm = fillScreenHeight === true &&
851
+ hideSectionTitles !== true &&
852
+ headerRegion === undefined &&
853
+ !insideDrawer;
854
+ const titleInShell = PageHeader !== undefined &&
855
+ pageHeaderSlotAvailable &&
856
+ hideSectionTitles !== true &&
857
+ headerRegion === undefined;
858
+ const menuPageHeaderActions = titleInShell && isScreenForm && ActionOverflowMenu !== undefined;
859
+ const cancelInHeaderMenu = menuPageHeaderActions && isWizard && onCancel !== undefined;
860
+ // Two groups — wizard navigation on the right/top, record actions on the
861
+ // left/below; destructive action sits outermost, farthest from the target
862
+ // action.
863
+ const hasSecondaryFormActions = (!menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined)) ||
864
+ (actions !== undefined && actions.length > 0) ||
865
+ (onCancel !== undefined && !cancelInHeaderMenu);
866
+ // Teil-C collapse rule: driven by the custom `actions` group only — a
867
+ // resolved true also folds the Copy-Link button (same visual group in the
868
+ // footer), but never Delete/Cancel, which keep their text regardless.
869
+ const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
870
+ const headerMenuItems = menuPageHeaderActions
871
+ ? [
872
+ ...(onCopyLink !== undefined
873
+ ? [
874
+ {
875
+ id: "copy-link",
876
+ label: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink"),
877
+ icon: "link",
878
+ onSelect: () => {
879
+ void Promise.resolve(onCopyLink()).then(() => setLinkCopied(true));
880
+ },
881
+ },
882
+ ]
883
+ : []),
884
+ ...(cancelInHeaderMenu
885
+ ? [
886
+ {
887
+ id: "cancel",
888
+ label: translate("kumiko.actions.cancel"),
889
+ icon: "x",
890
+ onSelect: () => onCancel(),
891
+ },
892
+ ]
893
+ : []),
894
+ ...(onDelete !== undefined
895
+ ? [
896
+ {
897
+ id: "delete",
898
+ label: translate("kumiko.actions.delete"),
899
+ icon: "trash",
900
+ variant: "danger",
901
+ onSelect: () => setConfirmDeleteOpen(true),
902
+ },
903
+ ]
904
+ : []),
905
+ ]
906
+ : [];
907
+ 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 && {
908
+ size: "icon",
909
+ ariaLabel: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink"),
910
+ }), onClick: async () => {
911
+ await onCopyLink();
912
+ setLinkCopied(true);
913
+ }, children: iconOnlyMidActions
914
+ ? null
915
+ : 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") }))] }));
916
+ const headerSlot = screen.slots?.header;
917
+ 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;
918
+ // A function headerRegion places the slot itself (projectionDetail puts it
919
+ // into its header card next to the actions); otherwise the slot renders
920
+ // above the caller's own headerRegion.
921
+ const formHeaderRegion = typeof headerRegion === "function" ? (headerRegion(headerSlotMount)) : headerSlotMount !== undefined ? (_jsxs(_Fragment, { children: [headerSlotMount, headerRegion] })) : (headerRegion);
922
+ const titleActionSlot = screen.slots?.titleAction;
923
+ 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;
924
+ // A projectionDetail's synthesized entity is empty when every section is an
925
+ // extension or relatedList; that is not a fieldless submit form, so it must not
926
+ // draw a no-op Save. Extension registrations still get their Save, since
927
+ // persistExtensions() runs on the customSubmit path too.
928
+ const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
929
+ const showsSubmit = isFormEditable || hasExtensionRegistrations || isFieldlessSubmitForm;
930
+ const footerSlot = screen.slots?.footer;
931
+ // Mirrors every branch inside formActions below — without this guard
932
+ // DefaultForm renders an empty footer strip (border + padding, no content)
933
+ // on read-only detail screens, since `actions` would otherwise always be
934
+ // a defined (if empty) fragment.
935
+ const hasFormActions = (isWizard && currentStep > 0) ||
936
+ (isWizard && !isLastWizardStep) ||
937
+ (showsSubmit && (!isWizard || isLastWizardStep)) ||
938
+ footerSlot !== undefined;
939
+ const nextStepTitle = isWizard ? filteredSections[currentStep + 1]?.title : undefined;
940
+ const formActions = (_jsxs(_Fragment, { children: [isWizard && currentStep > 0 && (_jsx(Button, { type: "button", variant: "secondary", icon: "chevron-left", onClick: handleWizardBack, testId: "render-edit-wizard-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, hasUnsavedChanges: snapshot.isDirty || extensionDirty, 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") })), isWizard && !isLastWizardStep && (_jsx(Button, { type: "submit", variant: "primary", iconEnd: "chevron-right", testId: "render-edit-wizard-next", children: nextStepTitle !== undefined
941
+ ? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
942
+ : translate("kumiko.actions.next") })), showsSubmit && (!isWizard || isLastWizardStep) && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
943
+ isSubmitting ||
944
+ disabled, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save")) }))] }));
945
+ // Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
946
+ // screen:<id>.<mode>.title / .<mode>.subtitle
947
+ // Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
948
+ // title falls back to screenId; subtitle falls back to screen.description,
949
+ // itself run through translate() so an i18n key (the established
950
+ // convention for description, e.g. bundled-features) resolves instead of
951
+ // showing the raw key — translate() returns the input unchanged for plain
952
+ // prose, same as it does for an unknown key (fw#2723 review). Same slot
953
+ // section.description already fills for a section. Falls back to
954
+ // undefined (no subtitle) when neither is set.
955
+ const isCreate = (() => {
956
+ const id = resolveExtensionEntityId(entityIdProp, vm.id);
957
+ return id == null || id === "";
958
+ })();
959
+ const formMode = isCreate ? "create" : "edit";
960
+ const resolveScreenText = (suffix) => {
961
+ for (const key of [
962
+ `screen:${screen.id}.${formMode}.${suffix}`,
963
+ `screen:${screen.id}.${suffix}`,
964
+ ]) {
965
+ const value = translate(key);
966
+ if (value !== key)
967
+ return value;
968
+ }
969
+ return undefined;
970
+ };
971
+ const formTitle = resolveScreenText("title") ?? screen.id;
972
+ // screen.description is head-card copy, not tab content — in tabs mode
973
+ // (hideSectionTitles) the head card already carries title/subtitle/status.
974
+ const formSubtitle = hideSectionTitles !== true
975
+ ? (resolveScreenText("subtitle") ??
976
+ (screen.description !== undefined ? translate(screen.description) : undefined))
977
+ : undefined;
978
+ const usesFieldFlow = isScreenForm || insideDrawer;
979
+ const allRequiredHint = (_jsx(Text, { variant: "muted", testId: "render-edit-all-required-hint", children: translate("kumiko.form.all-required") }));
980
+ const stepRail = isWizard && isScreenForm && StepBar !== undefined;
981
+ const changedFieldCount = isCreate ? 0 : Object.keys(snapshot.changes).length;
982
+ const unsavedCount = isCreate || !isScreenForm
983
+ ? 0
984
+ : changedFieldCount > 0
985
+ ? changedFieldCount
986
+ : extensionDirty
987
+ ? 1
988
+ : 0;
989
+ const sectionNavItems = isScreenForm && !isStepped
990
+ ? filteredSections.flatMap((section, index) => section.kind !== "extension" &&
991
+ section.kind !== "relatedList" &&
992
+ section.kind !== "writeForm" &&
993
+ section.visible &&
994
+ section.title !== undefined &&
995
+ section.title !== formTitle
996
+ ? [{ id: formSectionDomId(index), title: section.title }]
997
+ : [])
998
+ : [];
999
+ const compactStepLabel = (() => {
1000
+ const currentTitle = filteredSections[currentStep]?.title;
1001
+ return currentTitle !== undefined
1002
+ ? translate("kumiko.wizard.step-with-title", {
1003
+ current: currentStep + 1,
1004
+ total: lastStepIndex + 1,
1005
+ title: currentTitle,
1006
+ })
1007
+ : translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
1008
+ })();
1009
+ return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(headerMenuItems.length > 0 &&
1010
+ ActionOverflowMenu !== undefined && {
1011
+ actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
1012
+ }) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true && !titleInShell && { title: formTitle }), ...(formSubtitle !== undefined && !stepRail && { subtitle: formSubtitle }), ...(isScreenForm && { screenForm: true }), ...(unsavedCount > 0 && { unsavedCount }), ...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems }), ...(stepRail && {
1013
+ sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardJumpBack, orientation: "vertical", heading: translate("kumiko.wizard.step", {
1014
+ current: currentStep + 1,
1015
+ total: lastStepIndex + 1,
1016
+ }), ...(formSubtitle !== undefined && { description: formSubtitle }), testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" })),
1017
+ }), ...(hideActions !== true && hasFormActions && { actions: formActions }), ...(hideActions !== true &&
1018
+ 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: [showsAllRequiredHint && !insideDrawer && allRequiredHint, draftCandidates !== null && (_jsx(Banner, { variant: "info", testId: "render-edit-draft-picker", actions: [
1019
+ ...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))),
1020
+ _jsx(Button, { type: "button", variant: "link", onClick: () => setDraftCandidates(null), testId: "render-edit-draft-start-new", children: translate("kumiko.form.draft.start-new") }, "start-new"),
1021
+ ], children: _jsx(Text, { children: translate(draftCandidates.length === 1
1022
+ ? "kumiko.form.draft.resume-single"
1023
+ : "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: handleWizardJumpBack, testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" }))] })), isTabs &&
1024
+ (() => {
1025
+ // No Tabs primitive registered → every section renders stacked
1026
+ // (stepHidden below is gated on the same condition), which is the
1027
+ // single-layout behaviour. Degrading to one long form beats
1028
+ // hiding sections behind a strip that never drew.
1029
+ if (Tabs === undefined)
1030
+ return null;
1031
+ return (_jsx(Tabs, { testId: "render-edit-tabs", items: filteredSections.map((section, index) => ({
1032
+ id: tabIdAt(section, index),
1033
+ label: section.title ?? "",
1034
+ })), activeId: tabIdAt(filteredSections[currentStep], currentStep), onSelect: (id) => {
1035
+ const index = filteredSections.findIndex((s, i) => tabIdAt(s, i) === id);
1036
+ // skip: the strip reported an id no section owns.
1037
+ if (index === -1)
1038
+ return;
1039
+ setRawStep(index);
1040
+ } }));
1041
+ })(), filteredSections.map((section, sectionIndex) => {
1042
+ // turns this section's own `actions` (RowAction[]) into
1043
+ // already-bound buttons via the caller-supplied builder, rendered
1044
+ // through the same RenderEditActionButton the top-level `actions`
1045
+ // prop uses (same style/confirm/icon/error handling).
1046
+ const sectionActions = section.actions;
1047
+ const sectionActionsEl = sectionActions !== undefined && sectionActions.length > 0
1048
+ ? buildSectionActions?.(sectionActions)?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id)))
1049
+ : undefined;
1050
+ // Wizard steps and tabs stay mounted while off-screen (native
1051
+ // `hidden`, not unmounted) so an extension section's submit-registry
1052
+ // entry (useExtensionFormSubmit → registry.remove on unmount)
1053
+ // survives navigating past its step — otherwise Finish only ran the
1054
+ // last mounted step's handler and silently dropped earlier steps'
1055
+ // writes. For tabs this is also what makes ONE submit cover every
1056
+ // tab, including tabs the user never opened (fw#3134).
1057
+ const stepHidden = isStepped && sectionIndex !== currentStep;
1058
+ const wrapWizardStep = (key, el) => {
1059
+ if (!isStepped)
1060
+ return el;
1061
+ if (WizardStepGroup === undefined) {
1062
+ throw new Error(`RenderEdit: ${screen.layout.mode} layout requires primitives.WizardStepGroup, but none is registered.`);
1063
+ }
1064
+ return (_jsx(WizardStepGroup, { hidden: stepHidden, ...(hideSectionTitles === true && { inset: true }), children: el }, key));
1065
+ };
1066
+ // Off-screen wizard steps stay mounted (see comment above) but must
1067
+ // not participate in native constraint validation, or the Next
1068
+ // button's `type="submit"` triggers the browser's full-form check
1069
+ // — including required fields on unvisited steps, which are not
1070
+ // focusable while hidden, so the wizard silently stops navigating
1071
+ // (only a console warning, no visible error). A plain `hidden`
1072
+ // attribute does NOT bar descendants from constraint validation on
1073
+ // web — see WizardStepGroupProps for what implementations must
1074
+ // guarantee.
1075
+ if (section.kind === "extension") {
1076
+ const mount = (_jsx(ExtensionSectionMount, { section: section, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), initialValues: extensionInitialValues, values: snapshot.values,
1077
+ // @cast-boundary form-values: ExtensionSectionProps is not generic
1078
+ // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
1079
+ patch: patchAndScheduleDraftSave, validate: scopedValidate, hideTitle: hideSectionTitles, ...(hideSectionTitles !== true &&
1080
+ sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title));
1081
+ // Section always flattens to a borderless divider inside this
1082
+ // component's own <Form>, so tabs mode frames it with Card
1083
+ // instead — same reason the fields-section tabs branch below
1084
+ // stays on Card. No title here: the tab strip already labels the
1085
+ // panel (matches the non-tabs Section branch above via hideTitle).
1086
+ // testId stays only on the inner Section — this outer Card is
1087
+ // purely chrome, giving it the same testId would register two
1088
+ // elements under one id.
1089
+ const wrapped = hideSectionTitles === true ? (_jsx(Card, { ...(sectionActionsEl !== undefined && {
1090
+ slots: { headerActions: sectionActionsEl },
1091
+ }), children: mount }, section.title)) : (mount);
1092
+ return wrapWizardStep(section.title, wrapped);
1093
+ }
1094
+ if (section.kind === "relatedList") {
1095
+ // parentId is the displayed record's id — without it there's no
1096
+ // parent row whose related rows could be queried (fw#2166).
1097
+ // Rejected at boot in wizard layouts, so no WizardStepGroup here.
1098
+ const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
1099
+ if (parentId === null)
1100
+ return null;
1101
+ const mount = (_jsx(RelatedListSection, { section: section, parentId: parentId,
1102
+ // @cast-boundary form-values: TValues ist strukturell ein Record.
1103
+ record: snapshot.values, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, ...(fillScreenHeight === true && { grow: true }), ...(hideSectionTitles !== true &&
1104
+ sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(onRelatedListDrawerAction !== undefined && {
1105
+ onOpenDrawer: onRelatedListDrawerAction,
1106
+ }) }, section.title));
1107
+ // Tabs mode: the table runs full-bleed under the tab strip (board
1108
+ // layout); the toolbar buttons live in the list's own toolbar.
1109
+ return hideSectionTitles === true ? (_jsx("div", { className: fillScreenHeight === true ? "flex min-h-0 flex-1 flex-col" : undefined, children: mount }, section.title)) : (mount);
1110
+ }
1111
+ if (section.kind === "writeForm") {
1112
+ // Own submit button + dispatcher call, entirely independent of
1113
+ // this screen's (nonexistent, on projectionDetail) form submit —
1114
+ // rejected at boot in wizard layouts, so no WizardStepGroup here.
1115
+ return (_jsx(WriteFormSection, { section: section, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, onSubmitted: () => onReload?.(), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title ?? `write-form-${sectionIndex}`));
1116
+ }
1117
+ if (!section.visible)
1118
+ return null;
1119
+ // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
1120
+ const sectionKey = section.title ?? `section-${sectionIndex}`;
1121
+ const renderFieldGrid = (fields, columns, gridKey) => (_jsx(Grid, { columns: columns, ...(usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(usesFieldFlow && { flow: true }), issues: snapshot.errors[field.field], onChange: (v) => {
1122
+ controller.setField(field.field, v);
1123
+ }, GridCell: GridCell, featureName: featureName, ...(labelAppendix !== undefined && {
1124
+ labelAppendix: labelAppendix(field.field),
1125
+ }), ...(fieldAppendix !== undefined && {
1126
+ fieldAppendix: fieldAppendix(field.field),
1127
+ }), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(!isCreate && field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
1128
+ // Tabs mode renders one or more titled cards, so this card's actual
1129
+ // heading doesn't visually duplicate the short Tab strip label.
1130
+ // Stays `Card`, not `Section` — Section always flattens to a
1131
+ // borderless divider when rendered inside this component's own
1132
+ // `<Form>` (InsideFormContext), even with `chromeless` set, so it
1133
+ // cannot stand in for the tabs-mode card here.
1134
+ if (hideSectionTitles === true) {
1135
+ if (section.groups !== undefined) {
1136
+ 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.title))) }, `${sectionKey}-groups`));
1137
+ // `actions` on a `groups` section has no group of its own to
1138
+ // sit in and would need an extra title-less Card around the
1139
+ // group cards (Card-in-Card) — the boot-validator rejects that
1140
+ // combination in tabs mode, so `sectionActionsEl` is always
1141
+ // undefined here at runtime.
1142
+ return wrapWizardStep(sectionKey, groupsGrid);
1143
+ }
1144
+ // No section title here (fw#3218) — the Tab strip right above
1145
+ // already names this panel, so a repeated Card title would just
1146
+ // duplicate it. The title row only exists to carry actions, and
1147
+ // then it carries only the actions, never the title text.
1148
+ const cardEl = (_jsx(Card, { ...(sectionActionsEl !== undefined && {
1149
+ slots: { headerActions: sectionActionsEl },
1150
+ }), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`) }, sectionKey));
1151
+ return wrapWizardStep(sectionKey, cardEl);
1152
+ }
1153
+ // Suppress the section header when it would just repeat the form
1154
+ // title verbatim (typical for single-section actionForms), or the
1155
+ // tab label right above it. Without a Tabs primitive the sections
1156
+ // render stacked, and then the titles are what tells them apart.
1157
+ const repeatsTabLabel = isTabs && isStepped;
1158
+ const sectionTitle = section.title === formTitle || repeatsTabLabel ? undefined : section.title;
1159
+ const sectionEl = (_jsxs(Section, { ...(sectionNavItems.some((item) => item.id === formSectionDomId(sectionIndex)) && {
1160
+ id: formSectionDomId(sectionIndex),
1161
+ }), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }), testId: `section-${sectionKey}`, children: [showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint, renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)] }, sectionKey));
1162
+ return wrapWizardStep(sectionKey, sectionEl);
1163
+ }), formError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
1164
+ onReload();
1165
+ setFormError(null);
1166
+ }, 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) }) })), 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 && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
1167
+ await onDelete();
1168
+ }, testId: "render-edit-delete-dialog" }))] }) })] }));
1169
+ }