@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
@@ -1,1871 +0,0 @@
1
- import type {
2
- EntityEditScreenDefinition,
3
- FieldCondition,
4
- PlatformComponent,
5
- } from "@cosmicdrift/kumiko-framework/ui-types";
6
- import {
7
- evalFieldCondition,
8
- isFieldsEditSection,
9
- normalizeEditField,
10
- PROJECTION_DETAIL_ENTITY,
11
- } from "@cosmicdrift/kumiko-framework/ui-types";
12
- import type {
13
- DispatcherError,
14
- EditExtensionSectionViewModel,
15
- EditFieldViewModel,
16
- EditSectionViewModel,
17
- FieldConditions,
18
- FormValues,
19
- SubmitResult,
20
- } from "@cosmicdrift/kumiko-headless";
21
- import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
22
- import { RenderEditActionButton } from "./render-edit-action-button";
23
- import type { RenderEditProps } from "./render-edit-types";
24
- import { AllFieldsRequiredProvider } from "./render-field";
25
-
26
- export type {
27
- RenderEditAction,
28
- RenderEditChangeState,
29
- RenderEditControls,
30
- RenderEditProps,
31
- } from "./render-edit-types";
32
-
33
- import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
34
- import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
35
- import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
36
- import { useOptionalDispatcher } from "../context/dispatcher-context";
37
- import { useDraftStorage } from "../context/draft-storage-context";
38
- import { formatWhen } from "../format-when";
39
- import { useForm } from "../hooks/use-form";
40
- import { useTranslation } from "../i18n";
41
- import { useInsideDrawer } from "../inside-drawer";
42
- import { usePageHeaderSlotAvailable } from "../page-header-slot";
43
- import {
44
- type ActionMenuItemSpec,
45
- type FormSectionNavItem,
46
- STICKY_PRIMARY_ACTION_PROP,
47
- type StickyPrimaryActionMarker,
48
- shouldRenderActionsIconOnly,
49
- usePrimitives,
50
- } from "../primitives";
51
- import { GridCellForField } from "./grid-cell-for-field";
52
- import { RelatedListSection } from "./related-list-section";
53
- import {
54
- filterEditSections,
55
- hasEditableSection,
56
- hasIssueWithoutRenderedField,
57
- resolveExtensionEntityId,
58
- shouldNotifyCaller,
59
- } from "./render-edit-logic";
60
- import { WriteFormSection } from "./write-form-section";
61
-
62
- // Qualified names of the bundled `form-draft` feature. Hardcoded because the
63
- // renderer must not depend on @cosmicdrift/kumiko-bundled-features; a screen
64
- // with `layout.draft: true` and that feature unmounted is a boot error, so
65
- // these can never dangle silently.
66
- const FORM_DRAFT_GET = "form-draft:query:get";
67
- const FORM_DRAFT_SAVE = "form-draft:write:save";
68
- const FORM_DRAFT_DISCARD = "form-draft:write:discard";
69
- const FORM_DRAFT_LIST = "form-draft:query:list";
70
- // Mirrors bundled-features/src/form-draft/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
71
- // — same hardcoding rationale as the QNs above (no dependency on that package).
72
- const FORM_DRAFT_KEY_MAX_LENGTH = 256;
73
-
74
- // Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
75
- // patch() call (VIN-decode, an extension section) should not save immediately
76
- // per call, but patch() can also fire from inside onChange on every keystroke
77
- // (the #1888 derived-field shape) — 500ms collapses a typing burst into one
78
- // save instead of one per keystroke, while still saving well before a user
79
- // abandons the tab.
80
- export const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
81
-
82
- type FormDraftBlob = {
83
- readonly values: Record<string, unknown>;
84
- readonly stepIndex: number;
85
- };
86
-
87
- type DraftCandidate = {
88
- readonly id: string;
89
- readonly draftKey: string;
90
- readonly stepIndex: number;
91
- readonly savedAt: string;
92
- };
93
-
94
- // draftKey convention for a create-mode draft (framework-wizard-mode.md,
95
- // lookup.ts): `${screenId}:new:${draftId}`. Shared by the mount-time list
96
- // fallback (strip the prefix to recover a candidate's draftId) and the
97
- // list query itself (filter out edit-mode drafts, which share the same
98
- // `${screenId}:%` LIKE-prefix scan server-side).
99
- function newDraftPrefix(screenId: string): string {
100
- return `${screenId}:new:`;
101
- }
102
-
103
- // `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
104
- // without a polyfill — guard like dispatcher-live.ts's generateRequestId.
105
- function mintDraftId(): string {
106
- const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
107
- if (typeof c?.randomUUID === "function") return c.randomUUID();
108
- return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
109
- }
110
-
111
- // End-to-end renderer für einen entityEdit screen. Rendert aus-
112
- // schließlich über Primitives — kein raw HTML. Ein Native-Renderer
113
- // der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
114
- // Änderungen.
115
-
116
- function toConditionValue<TValues extends FormValues, TCtx>(
117
- cond: FieldCondition,
118
- ): NonNullable<FieldConditions<TValues, TCtx>["visible"]> {
119
- if (typeof cond === "boolean") return cond;
120
- // @cast-boundary form-values: TValues ist strukturell ein Record.
121
- return (values: TValues) => evalFieldCondition(cond, values as Record<string, unknown>);
122
- }
123
-
124
- // Tab-strip key. The boot-validator makes `id` mandatory on a tabs layout and
125
- // entityEdit only ever has "fields"/"extension" sections (relatedList and
126
- // writeForm are rejected outright), so the positional fallback is unreachable
127
- // for a tabs screen — it only keeps this total for other layouts.
128
- function tabIdAt(section: EditSectionViewModel | undefined, index: number): string {
129
- const declared =
130
- section?.kind === "fields" || section?.kind === "extension" ? section.id : undefined;
131
- return declared ?? `section-${index}`;
132
- }
133
-
134
- function deriveFormFields<TValues extends FormValues, TCtx>(
135
- screen: EntityEditScreenDefinition,
136
- ): Record<string, FieldConditions<TValues, TCtx>> {
137
- const out: Record<string, FieldConditions<TValues, TCtx>> = {};
138
- for (const section of screen.layout.sections) {
139
- // relatedList carries no `fields` for the form-condition map either.
140
- if (!isFieldsEditSection(section)) continue;
141
- for (const spec of section.fields) {
142
- const normalized = normalizeEditField(spec);
143
- out[normalized.field] = {
144
- ...(normalized.visible !== undefined && {
145
- visible: toConditionValue<TValues, TCtx>(normalized.visible),
146
- }),
147
- ...(normalized.readOnly !== undefined && {
148
- readonly: toConditionValue<TValues, TCtx>(normalized.readOnly),
149
- }),
150
- ...(normalized.required !== undefined && {
151
- required: toConditionValue<TValues, TCtx>(normalized.required),
152
- }),
153
- };
154
- }
155
- }
156
- return out;
157
- }
158
-
159
- // Resolves an extension-section's `{ react: { __component: "X" } }` marker
160
- // to a registered React component via ExtensionSectionsProvider (filled in
161
- // createKumikoApp from clientFeatures.extensionSectionComponents) and
162
- // mounts it with the host entity name + id. Hook lives in its own
163
- // component so we don't call `use*` inside vm.sections.map (rules-of-
164
- // hooks would punish reordering sections between renders).
165
- function ExtensionSectionMount({
166
- section,
167
- entityName: hostEntityName,
168
- entityId,
169
- initialValues,
170
- values,
171
- patch,
172
- validate,
173
- hideTitle,
174
- actions,
175
- }: {
176
- readonly section: EditExtensionSectionViewModel;
177
- readonly entityName: string;
178
- readonly entityId: string | null;
179
- readonly initialValues?: Readonly<Record<string, unknown>>;
180
- readonly values?: Readonly<Record<string, unknown>>;
181
- readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
182
- readonly validate?: () => boolean;
183
- readonly hideTitle?: boolean;
184
- /** section.actions, already resolved into buttons by the caller. */
185
- readonly actions?: ReactNode;
186
- }): ReactNode {
187
- const { Banner, FillContainer, Section, Text } = usePrimitives();
188
- const name = extensionSectionName(section.component);
189
- const Component = useExtensionSectionComponent(name);
190
- const testId = `section-extension-${section.title}`;
191
- if (Component === undefined) {
192
- const placeholder = (
193
- <Banner variant="info" testId={`section-extension-placeholder-${section.title}`}>
194
- <Text>
195
- Extension section component <Text variant="code">{name ?? "(no __component name)"}</Text>{" "}
196
- not registered in clientFeatures.extensionSectionComponents.
197
- </Text>
198
- </Banner>
199
- );
200
- // Tabs mode already frames this in the caller's own Card — a Section
201
- // here would add a second, unwanted divider inset (px-6 py-4) on top of
202
- // the Card's own padding, misaligning this tab's content against the
203
- // fields tab (Grid sits directly in the Card there). FillContainer keeps
204
- // the testId without adding chrome (same primitive RelatedListSection
205
- // uses for its own hideTitle branch).
206
- return hideTitle === true ? (
207
- FillContainer !== undefined ? (
208
- <FillContainer testId={testId}>{placeholder}</FillContainer>
209
- ) : (
210
- placeholder
211
- )
212
- ) : (
213
- <Section key={section.title} title={section.title} testId={testId}>
214
- {placeholder}
215
- </Section>
216
- );
217
- }
218
- const mounted = (
219
- <Component
220
- entityName={section.entityName ?? hostEntityName}
221
- entityId={entityId}
222
- initialValues={initialValues}
223
- values={values}
224
- patch={patch}
225
- validate={validate}
226
- />
227
- );
228
- // Same reasoning as the placeholder branch above: tabs mode's outer Card
229
- // is the only frame this content gets, no nested Section divider.
230
- return hideTitle === true ? (
231
- FillContainer !== undefined ? (
232
- <FillContainer testId={testId}>{mounted}</FillContainer>
233
- ) : (
234
- mounted
235
- )
236
- ) : (
237
- <Section {...(actions !== undefined && { actions })} title={section.title} testId={testId}>
238
- {mounted}
239
- </Section>
240
- );
241
- }
242
-
243
- // Resolves an entityEdit slot's (`screen.slots.header` / `.footer`)
244
- // `{ react: { __component: "X" } }` marker via the same ExtensionSectionsProvider
245
- // registry as ExtensionSectionMount / ListHeaderSlotMount and mounts it. One
246
- // component for both slots — header passes no hasUnsavedChanges/wizardStep,
247
- // footer does. Own component for rules-of-hooks (useExtensionSectionComponent
248
- // must not run conditionally inside RenderEdit's render body).
249
- function EditSlotMount({
250
- slot,
251
- slotName,
252
- screenId,
253
- entityName,
254
- entityId,
255
- values,
256
- hasUnsavedChanges,
257
- wizardStep,
258
- }: {
259
- readonly slot: PlatformComponent;
260
- readonly slotName: "header" | "titleAction" | "footer";
261
- readonly screenId: string;
262
- readonly entityName: string;
263
- readonly entityId: string | null;
264
- readonly values: Readonly<Record<string, unknown>>;
265
- readonly hasUnsavedChanges?: boolean;
266
- readonly wizardStep?: { readonly index: number; readonly isLast: boolean };
267
- } & StickyPrimaryActionMarker): ReactNode {
268
- const name = extensionSectionName(slot);
269
- const Component = useExtensionSectionComponent(name);
270
- useEffect(() => {
271
- if (name !== undefined && Component === undefined) {
272
- // biome-ignore lint/suspicious/noConsole: dev warning for a setup error
273
- console.warn(
274
- `[kumiko] Edit ${slotName} slot component "${name}" on screen "${screenId}" is not registered in clientFeatures.extensionSectionComponents — the ${slotName} slot renders nothing.`,
275
- );
276
- }
277
- }, [name, Component, screenId, slotName]);
278
- if (Component === undefined) return null;
279
- return (
280
- <Component
281
- entityName={entityName}
282
- entityId={entityId}
283
- values={values}
284
- {...(hasUnsavedChanges !== undefined && { hasUnsavedChanges })}
285
- {...(wizardStep !== undefined && { wizardStep })}
286
- />
287
- );
288
- }
289
-
290
- function formSectionDomId(sectionIndex: number): string {
291
- return `form-section-${sectionIndex}`;
292
- }
293
-
294
- export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
295
- props: RenderEditProps<TValues, TCtx>,
296
- ): ReactNode {
297
- const {
298
- screen,
299
- entity,
300
- featureName,
301
- initial,
302
- writeCommand,
303
- translate: translateProp,
304
- ctx,
305
- schema,
306
- onSubmit,
307
- payloadMode = "values",
308
- buildPayload,
309
- serverFieldPathPrefix,
310
- onDelete,
311
- onCancel,
312
- onReload,
313
- onDirtyChange,
314
- onCopyLink,
315
- actions,
316
- onRelatedListDrawerAction,
317
- submitLabel,
318
- submitVariant,
319
- summary,
320
- labelAppendix,
321
- fieldAppendix,
322
- entityId: entityIdProp,
323
- extensionInitialValues,
324
- onChange,
325
- onControlsReady,
326
- fields: fieldsFilter,
327
- disabled = false,
328
- hideActions,
329
- valueDisplay = "form",
330
- hideSectionTitles,
331
- fillScreenHeight,
332
- headerRegion,
333
- buildSectionActions,
334
- } = props;
335
- const { customSubmit } = props;
336
- // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
337
- // konsumieren wir den i18next-Context direkt. Sonst wären Field-
338
- // Labels ohne Caller-Wiring raw-Keys (`feature:entity:foo:field:title`).
339
- // useTranslation throwt ohne LocaleProvider — das ist ok, weil RenderEdit
340
- // ohnehin nur in einem mounted Kumiko-App-Tree läuft.
341
- const t = useTranslation();
342
- const translate = translateProp ?? t;
343
- const dispatcher = useOptionalDispatcher();
344
-
345
- const isWizard = screen.layout.mode === "wizard";
346
- // Tabs reuse the wizard's step machinery: one section visible at a time,
347
- // all of them mounted, `currentStep` as the active index. What they do NOT
348
- // share is navigation — a tab form submits from any tab, so every
349
- // step-transition path below stays wizard-only (fw#3134).
350
- const isTabs = screen.layout.mode === "tabs";
351
- const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
352
- const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
353
- const draftStorage = useDraftStorage();
354
- const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
355
- const [linkCopied, setLinkCopied] = useState(false);
356
- const [isSubmitting, setIsSubmitting] = useState(false);
357
- const [formError, setFormError] = useState<DispatcherError | null>(null);
358
- // One state for all header actions (actions?), not per-button — only one
359
- // action can be in flight at a time, and this keeps the error surfaced in
360
- // the shared formError-adjacent banner region instead of inside the
361
- // button row (fw#2166 review: a full-width Banner there breaks the
362
- // flex justify-end action bar).
363
- const [actionError, setActionError] = useState<string | null>(null);
364
- const [rawStep, setRawStep] = useState(0);
365
- // Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
366
- // on mount so a same-tab reload finds the right one of several parallel
367
- // create-sessions on this screen; `null` until the first step change
368
- // mints one (saveDraft), or the mount-time `form-draft:query:list`
369
- // fallback below adopts an existing one. Edit-mode never touches this —
370
- // its draftKey is `${screen.id}:${entityIdProp}` unconditionally.
371
- const [draftId, setDraftId] = useState<string | null>(() =>
372
- isCreateMode ? draftStorage.getDraftId(screen.id) : null,
373
- );
374
- // Marks a draftId this instance minted itself (saveDraft, first step
375
- // change) so the restore effect below doesn't immediately re-fetch what
376
- // it just wrote — there is nothing to restore, the row is brand new.
377
- const mintedDraftIdRef = useRef<string | null>(null);
378
- // Marks that the mount-time `form-draft:query:list` fallback already ran
379
- // once for this mount. Without this, a create-mode discard resets `draftId`
380
- // to `null` (see discardDraft below) and would re-arm the list effect —
381
- // silently adopting an unrelated parallel draft on the same screen right
382
- // after the user just submitted (#1908).
383
- const didListRef = useRef(false);
384
- const [draftCandidates, setDraftCandidates] = useState<readonly DraftCandidate[] | null>(null);
385
- // Composed-Save: Extension-Sections melden hier ihren dirty-State (damit der
386
- // Save-Button aktiv wird wenn NUR eine Section geändert wurde) + ihren
387
- // Submit-Handler (läuft nach dem Entity-Write). extensionErrorKey hält den
388
- // i18n-Key einer fehlgeschlagenen Section-Persistierung.
389
- //
390
- // Not a second write path for saveDraft()'s data (#1914): deriveFormFields
391
- // (above) skips extension sections entirely, so extension-owned field state
392
- // never enters `fields`/`controller.getSnapshot().values` — there is no
393
- // draft-blob-covered state left for persistExtensions() to compete over.
394
- const [extensionDirty, setExtensionDirty] = useState(false);
395
- const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
396
- const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
397
- const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(
398
- ({ anyDirty, hasRegistrations }) => {
399
- setExtensionDirty(anyDirty);
400
- setHasExtensionRegistrations(hasRegistrations);
401
- },
402
- );
403
- const {
404
- Button,
405
- Banner,
406
- Card,
407
- Dialog,
408
- Form,
409
- Section,
410
- Grid,
411
- GridCell,
412
- Text,
413
- Progress,
414
- StepBar,
415
- WizardStepGroup,
416
- Tabs,
417
- PageHeader,
418
- ActionOverflowMenu,
419
- } = usePrimitives();
420
- const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
421
- const insideDrawer = useInsideDrawer();
422
-
423
- // Both stepped layouts show one section at a time and keep the rest mounted
424
- // but inert. A tabs layout on a host without the Tabs primitive has no strip
425
- // to switch with, so it falls back to rendering every section stacked rather
426
- // than stranding the user on section 0 (fw#3134).
427
- const isStepped = isWizard || (isTabs && Tabs !== undefined);
428
- const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
429
-
430
- // Must be computed before submitConfig/useForm bakes it in (the controller
431
- // freezes it on first render). This is the unfiltered field-name set from
432
- // `fieldsFilter` ∩ `fields` — not the value-dependent `filteredSections`
433
- // (which also drops `section.visible === false`). Hidden fields still fall
434
- // out via form-controller's `hiddenFields` path. undefined = unscoped, since
435
- // scoping would silently drop root-level .refine() issues.
436
- // A `fieldsFilter` that matches nothing in `fields` (typo, renamed field)
437
- // must fall back to unscoped too — an empty scope array would filter out
438
- // ALL validation issues (kumiko-framework#1907), letting submit() through
439
- // unvalidated on a form that also renders no sections.
440
- const scopeFieldNames = useMemo(() => {
441
- if (fieldsFilter === undefined) return undefined;
442
- const matched = fieldsFilter.filter((f) => Object.hasOwn(fields, f));
443
- return matched.length === 0 ? undefined : matched;
444
- }, [fieldsFilter, fields]);
445
-
446
- // Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
447
- // customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
448
- // weil wir controller.submit() eh nicht rufen.
449
- const submitConfig =
450
- writeCommand !== undefined
451
- ? {
452
- type: writeCommand,
453
- payloadMode,
454
- ...(buildPayload !== undefined && { buildPayload }),
455
- ...(serverFieldPathPrefix !== undefined && { serverFieldPathPrefix }),
456
- ...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
457
- }
458
- : undefined;
459
-
460
- const { controller, snapshot } = useForm<TValues, TCtx>({
461
- initial,
462
- fields,
463
- ...(schema !== undefined && { schema }),
464
- ...(ctx !== undefined && { ctx }),
465
- ...(submitConfig !== undefined && { submit: submitConfig }),
466
- });
467
-
468
- // Derived from the screen id + the host entity id only — never from
469
- // `vm.id`, which lives in the form values a restore mutates (load under one
470
- // key, save under another). Edit-mode: `${screen.id}:${entityIdProp}`,
471
- // unchanged. Create-mode: `${screen.id}:new:${draftId}` once a draftId
472
- // exists (resumed, adopted, or minted), `undefined` before that — two
473
- // parallel create sessions on the same screen must never collapse onto
474
- // the same key (kumiko-framework#1908).
475
- const draftKey = useMemo(
476
- () =>
477
- isCreateMode
478
- ? draftId !== null
479
- ? `${screen.id}:new:${draftId}`
480
- : undefined
481
- : `${screen.id}:${entityIdProp}`,
482
- [screen.id, entityIdProp, isCreateMode, draftId],
483
- );
484
-
485
- // Controlled mode (Issue #1887). Both callbacks live in refs so a
486
- // re-rendering caller (identity-unstable `onChange`/`onControlsReady`
487
- // closures, e.g. inline arrows) doesn't retrigger these effects — only a
488
- // real snapshot mutation (typing, patch()) does. Without this, a caller
489
- // whose onChange calls patch() to fill other fields (the VIN-decode use
490
- // case from #1888) would loop: patch → new snapshot → effect
491
- // re-subscribes with a "new" onChange → refires.
492
- const onChangeRef = useRef(onChange);
493
- onChangeRef.current = onChange;
494
- // Guards against redelivering to the SAME callback identity on every
495
- // render (an inline-arrow onControlsReady would otherwise refire the
496
- // effect below on every render since the callback itself is now a dep).
497
- // Tracks the actual prop, not a ref snapshot, so a caller that swaps in a
498
- // real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
499
- // gets delivered to for THIS mount instead of never (fw#1899).
500
- const deliveredControlsToRef = useRef<typeof onControlsReady>(undefined);
501
- const onControlsReadyRef = useRef(onControlsReady);
502
- onControlsReadyRef.current = onControlsReady;
503
- const hasNavigatedRef = useRef(false);
504
- const submittedRef = useRef(false);
505
- const isSubmittingRef = useRef(false);
506
- const scopeFieldNamesRef = useRef(scopeFieldNames);
507
- scopeFieldNamesRef.current = scopeFieldNames;
508
- const scopedValidate = useCallback(
509
- () => controller.validate(scopeFieldNamesRef.current),
510
- [controller],
511
- );
512
-
513
- // `saveDraft` (defined below) is a fresh closure every render, over that
514
- // render's `draftKey`/`draftId` — the ref lets the debounce timer below
515
- // always call the CURRENT one even though the timer was scheduled by an
516
- // earlier render's patch() call. `currentStepRef` gives it the current
517
- // wizard step without depending on `currentStep` (computed further down,
518
- // from `filteredSections`) at the point patchAndScheduleDraftSave itself
519
- // is defined — kept fresh where `currentStep` is computed below.
520
- const saveDraftRef = useRef(saveDraft);
521
- saveDraftRef.current = saveDraft;
522
- // `handleSubmit` (defined below) is a fresh closure over this render's
523
- // snapshot/extensionDirty, while the onControlsReady effect fires once per
524
- // mount — the ref lets controls.submit() always reach the current one.
525
- const handleSubmitRef = useRef<() => Promise<void>>(async () => {});
526
- const currentStepRef = useRef(0);
527
- const draftSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
528
- useEffect(() => {
529
- return () => {
530
- if (draftSaveTimerRef.current !== null) {
531
- clearTimeout(draftSaveTimerRef.current);
532
- // Flush the pending debounced save instead of dropping it — a
533
- // navigation/unmount inside the debounce window would otherwise
534
- // silently discard the last patch() (discardDraft nulls this ref
535
- // first on the post-submit path, so this can't resurrect a
536
- // just-submitted draft).
537
- saveDraftRef.current(currentStepRef.current);
538
- }
539
- };
540
- }, []);
541
-
542
- // patch() (controlled mode, extension sections) applies immediately —
543
- // only the resulting draft save is debounced (#1914), so a patch() burst
544
- // (VIN-decode filling several fields, or onChange fanning a keystroke out
545
- // through patch(), see the #1888 test above) collapses into one save.
546
- const patchAndScheduleDraftSave = useCallback(
547
- (partial: Partial<TValues>) => {
548
- controller.setValues(partial);
549
- if (!draftEnabled || dispatcher === undefined || disabled || submittedRef.current) return;
550
- if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
551
- draftSaveTimerRef.current = setTimeout(() => {
552
- draftSaveTimerRef.current = null;
553
- if (disabled || submittedRef.current) return;
554
- saveDraftRef.current(currentStepRef.current);
555
- }, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
556
- },
557
- [controller, draftEnabled, dispatcher, disabled],
558
- );
559
-
560
- // Runs before the onChange effect below (declaration order = React
561
- // execution order) so that an onChange fired on mount always sees
562
- // `controls !== undefined` — both effects depend only on mount-stable
563
- // values (controller/scopedValidate/patchAndScheduleDraftSave), so the
564
- // swap is otherwise a no-op. See the #1888 VIN-decode test: an initial
565
- // value that should derive dependent fields on mount needs controls.patch
566
- // available on the very first onChange call, not just from the second
567
- // keystroke onward.
568
- const hasControlsReady = onControlsReady !== undefined;
569
- useEffect(() => {
570
- if (!hasControlsReady) return;
571
- const ready = onControlsReadyRef.current;
572
- if (ready === undefined) return;
573
- if (deliveredControlsToRef.current === ready) return;
574
- deliveredControlsToRef.current = ready;
575
- ready({
576
- patch: patchAndScheduleDraftSave,
577
- validate: scopedValidate,
578
- getValues: () => controller.getSnapshot().values,
579
- submit: () => handleSubmitRef.current(),
580
- });
581
- // Dep on boolean presence only — inline-arrow identity must not redeliver.
582
- }, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
583
-
584
- // `schema` is typically an inline caller prop (`schema={z.object({...})}`)
585
- // with unstable identity across renders — as an effect dep it would refire
586
- // on every parent render, not just on a snapshot change, risking a loop if
587
- // the caller's onChange triggers a parent re-render. Held in a ref like
588
- // onChangeRef so only a real snapshot mutation retriggers this effect.
589
- const schemaRef = useRef(schema);
590
- schemaRef.current = schema;
591
- // Controls are guaranteed ready by the time this fires (see the
592
- // onControlsReady effect above, which is declared first on purpose).
593
- useEffect(() => {
594
- const cb = onChangeRef.current;
595
- if (cb === undefined) return;
596
- // Dry-run parse against `schema` — NOT controller.validate(). Calling
597
- // validate() here would write field-level errors into snapshot.errors
598
- // on every keystroke, painting error messages while the user is still
599
- // typing. `valid` can therefore legitimately diverge from what's
600
- // currently rendered under the fields (the last *mutating* validate()
601
- // call, e.g. from controls.validate() or submit()).
602
- const currentSchema = schemaRef.current;
603
- const valid =
604
- currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
605
- cb({
606
- values: snapshot.values,
607
- changes: snapshot.changes,
608
- dirty: snapshot.isDirty,
609
- valid,
610
- submitting: isSubmitting,
611
- });
612
- }, [snapshot, isSubmitting]);
613
-
614
- const onDirtyChangeRef = useRef(onDirtyChange);
615
- onDirtyChangeRef.current = onDirtyChange;
616
- const hasUnsavedInput = snapshot.isDirty || extensionDirty;
617
- useEffect(() => {
618
- onDirtyChangeRef.current?.(hasUnsavedInput);
619
- }, [hasUnsavedInput]);
620
-
621
- useEffect(() => {
622
- // skip: this screen does not persist a draft.
623
- if (!draftEnabled || dispatcher === undefined) return;
624
- // skip: create-mode with no draftId yet — nothing to restore, either
625
- // the list-fallback effect below finds one or the first step change
626
- // mints a fresh one.
627
- if (draftKey === undefined) return;
628
- // skip: this draftId was just minted by saveDraft — the row it wrote
629
- // is exactly the current form state, re-fetching it would be a no-op
630
- // round-trip at best and a stale-echo clobber at worst.
631
- if (draftId !== null && draftId === mintedDraftIdRef.current) return;
632
- let cancelled = false;
633
- void (async () => {
634
- try {
635
- const result = await dispatcher.query<{ readonly draft: FormDraftBlob | null }>(
636
- FORM_DRAFT_GET,
637
- { draftKey },
638
- );
639
- // skip: superseded by a newer mount, or the draft lookup failed — an
640
- // unreachable draft store must not break the form itself.
641
- if (cancelled || !result.isSuccess) return;
642
- const draft = result.data?.draft ?? null;
643
- // skip: nothing saved yet for this key.
644
- if (draft === null) return;
645
- // skip: the user already typed or wizard-navigated while the lookup
646
- // was in flight — their input wins over the stored draft.
647
- if (controller.getSnapshot().isDirty || hasNavigatedRef.current) return;
648
- // @cast-boundary form-draft blob: `values` round-trips through an opaque
649
- // jsonb column and comes back untyped.
650
- controller.setValues(draft.values as Partial<TValues>);
651
- setRawStep(Math.max(draft.stepIndex, 0));
652
- } catch (err: unknown) {
653
- // biome-ignore lint/suspicious/noConsole: draft restore must not red-screen the form
654
- console.warn("render-edit: draft restore query failed", err);
655
- }
656
- })();
657
- return () => {
658
- cancelled = true;
659
- };
660
- }, [draftEnabled, draftKey, draftId, dispatcher, controller]);
661
-
662
- // Mount-time fallback (issue #1913) for create-mode when no draftId
663
- // survived in storage (new tab, cleared storage): ask the server for this
664
- // screen's open drafts and let the user choose via a picker (below) —
665
- // even for exactly one candidate. Auto-adopting a lone candidate would
666
- // silently hand this tab someone else's in-progress draft from a parallel
667
- // session on the same screen (cross-tab hijack); the picker's "start new"
668
- // action covers the common case (it really was this tab's own draft) with
669
- // one extra click. Zero candidates → stay null, saveDraft mints a fresh
670
- // draftId on the first step change same as any other fresh create.
671
- useEffect(() => {
672
- // skip: this screen does not persist a draft, this is edit-mode, a
673
- // draftId is already known (from storage or an earlier adoption), or
674
- // this mount already ran the list lookup once (didListRef).
675
- if (
676
- !draftEnabled ||
677
- !isCreateMode ||
678
- draftId !== null ||
679
- didListRef.current ||
680
- dispatcher === undefined
681
- )
682
- return;
683
- let cancelled = false;
684
- void (async () => {
685
- try {
686
- const result = await dispatcher.query<{ readonly drafts: readonly DraftCandidate[] }>(
687
- FORM_DRAFT_LIST,
688
- { screenId: screen.id },
689
- );
690
- // skip: superseded by a newer mount, or the lookup failed — an
691
- // unreachable draft store must not break a fresh create.
692
- if (cancelled || !result.isSuccess) return;
693
- didListRef.current = true;
694
- const prefix = newDraftPrefix(screen.id);
695
- // list's LIKE-prefix scan also matches edit-mode drafts
696
- // (`${screenId}:${entityId}`) — keep only create-mode ones.
697
- const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
698
- // skip: no open drafts for this screen — stay null, saveDraft mints a
699
- // fresh draftId on the first step change same as any other create.
700
- if (candidates.length === 0) return;
701
- setDraftCandidates(candidates);
702
- } catch (err: unknown) {
703
- // biome-ignore lint/suspicious/noConsole: draft lookup must not red-screen the form
704
- console.warn("render-edit: draft list query failed", err);
705
- }
706
- })();
707
- return () => {
708
- cancelled = true;
709
- };
710
- }, [draftEnabled, isCreateMode, draftId, dispatcher, screen.id]);
711
-
712
- // User picked one of several open drafts from the mount-time picker
713
- // (see draftCandidates below) — adopt it the same way a single
714
- // auto-adopted candidate would be.
715
- function adoptDraft(candidate: DraftCandidate): void {
716
- // Locked state (#1896/fw#1909): `disabled` means "no write possible" —
717
- // repointing draftKey and patching in the candidate's values is exactly
718
- // that, so a locked form must not adopt a draft.
719
- if (disabled) return;
720
- const adoptedId = candidate.draftKey.slice(newDraftPrefix(screen.id).length);
721
- draftStorage.setDraftId(screen.id, adoptedId);
722
- setDraftId(adoptedId);
723
- setDraftCandidates(null);
724
- }
725
-
726
- const vm = useMemo(
727
- () =>
728
- computeEditViewModel({
729
- screen,
730
- entity,
731
- values: snapshot.values,
732
- translate,
733
- featureName,
734
- }),
735
- [screen, entity, snapshot.values, translate, featureName],
736
- );
737
-
738
- const filteredSections = useMemo(
739
- // A fully-hidden "fields" section (every field in it currently
740
- // condition-hidden) must not occupy a wizard step; it would render
741
- // empty and block Back/Next on nothing. Extension, relatedList and
742
- // writeForm sections carry no `visible` (they own their own lifecycle /
743
- // run their own query / own submit), so they always pass through. A
744
- // section with no fields at all (e.g. a review-only step) has
745
- // `visible: fields.some(...)` = false vacuously; that's "no fields to
746
- // hide", not "hidden", so it stays too (fw#1901).
747
- () =>
748
- filterEditSections(vm.sections, fieldsFilter).filter(
749
- (section) =>
750
- section.kind === "extension" ||
751
- section.kind === "relatedList" ||
752
- section.kind === "writeForm" ||
753
- section.fields.length === 0 ||
754
- section.visible,
755
- ),
756
- [vm.sections, fieldsFilter],
757
- );
758
-
759
- const allFieldsRequired = useMemo(() => {
760
- const fillable = filteredSections.flatMap((section) =>
761
- section.kind === "fields"
762
- ? section.fields.filter((field) => field.visible && !field.readOnly)
763
- : [],
764
- );
765
- return fillable.length > 0 && fillable.every((field) => field.required);
766
- }, [filteredSections]);
767
-
768
- const showsAllRequiredHint = allFieldsRequired && hideSectionTitles !== true && !isWizard;
769
-
770
- // true when editable fields exist or an extension opted into composed submit (fw#2359).
771
- const isFormEditable = hasEditableSection(filteredSections);
772
- // A fieldless form (input-less secretMint mint, fw#2838 — the secret is
773
- // server-generated, the mint step is only its submit button) has no
774
- // section to gate on: it IS the action, so it always shows the submit
775
- // button, and it can never go dirty either.
776
- const isFieldless = Object.keys(entity.fields).length === 0;
777
-
778
- // A lone relatedList tab (`hideSectionTitles` is only ever set by
779
- // ProjectionDetailBody, which hands this component ONE section — its
780
- // synthesized screen drops layout.mode, so an entityEdit tabs layout never
781
- // reaches this line and `filteredSections[0]` stays the active section)
782
- // needs its own tab panel capped at the available height so its table
783
- // scrolls inside the panel instead of the whole page stretching to the
784
- // row count (fw#2722) — a short table still sizes to its content instead
785
- // of stretching the panel to the bottom (fw#2778). Any other layout —
786
- // multiple sections, a non-relatedList tab, stacked (non-tabs) forms —
787
- // keeps normal document-flow height untouched.
788
- const fillHeight =
789
- fillScreenHeight === true ||
790
- (hideSectionTitles === true && filteredSections[0]?.kind === "relatedList");
791
-
792
- // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
793
- // false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
794
- // Entity-Kontext (create-mode ohne route-id) gibt es nichts zu schreiben.
795
- async function persistExtensions(): Promise<boolean> {
796
- const entityId = resolveExtensionEntityId(entityIdProp, vm.id);
797
- if (entityId === null) return true;
798
- const results = await runExtensionSubmits({ entityId });
799
- const failed = results.find((r) => !r.isSuccess);
800
- if (failed !== undefined) {
801
- setExtensionErrorKey(failed.errorKey ?? "kumiko.form.extension.save-failed");
802
- return false;
803
- }
804
- return true;
805
- }
806
-
807
- const lastStepIndex = Math.max(filteredSections.length - 1, 0);
808
- // Clamped on read, not on write: section visibility is value-dependent, so a
809
- // stored stepIndex can point past what is currently rendered — that lands on
810
- // the last step instead of an empty one.
811
- const currentStep = Math.min(rawStep, lastStepIndex);
812
- // Kept fresh for patchAndScheduleDraftSave's debounce timer above, which
813
- // is defined before `currentStep` exists (it depends on `filteredSections`,
814
- // computed further up from `vm`) and so cannot close over it directly.
815
- currentStepRef.current = currentStep;
816
- const isLastWizardStep = currentStep >= lastStepIndex;
817
-
818
- // Step transitions only — never per keystroke. Deliberately not awaited: a
819
- // failed draft save must not block the step change.
820
- //
821
- // Create-mode, first draft save (step change OR a debounced patch() from
822
- // controlled mode / extension sections, see patchAndScheduleDraftSave
823
- // above — both call this on step 0 too): mints the draftId here (issue
824
- // #1913) rather than at mount, so a form nobody ever interacted with
825
- // never claims a draftId or writes a row. `draftKey`/`draftId` state
826
- // won't reflect the mint until the next render, so the just-minted key is
827
- // computed inline instead of read from the memoized `draftKey`.
828
- function saveDraft(stepIndex: number): void {
829
- // skip: this screen does not persist a draft.
830
- if (!draftEnabled || dispatcher === undefined) return;
831
- let key = draftKey;
832
- if (isCreateMode && draftId === null) {
833
- const mintedId = mintDraftId();
834
- mintedDraftIdRef.current = mintedId;
835
- draftStorage.setDraftId(screen.id, mintedId);
836
- setDraftId(mintedId);
837
- key = `${screen.id}:new:${mintedId}`;
838
- // A stale picker from the mount-time list fallback must not survive a
839
- // mint — picking a candidate afterwards would repoint draftKey at an
840
- // unrelated draft mid-edit and overwrite it (#1908).
841
- setDraftCandidates(null);
842
- }
843
- // skip: create-mode with a draftId that failed to mint — unreachable
844
- // in practice (mint above always produces one), kept as a type-level
845
- // guard against a stale `undefined` key ever reaching the write.
846
- if (key === undefined) return;
847
- // draftKeySchema.max(FORM_DRAFT_KEY_MAX_LENGTH) rejects this server-side —
848
- // catch it here instead of losing the save silently to the fire-and-forget
849
- // write below (the user would only notice on resume, with nothing to resume).
850
- if (key.length > FORM_DRAFT_KEY_MAX_LENGTH) {
851
- // biome-ignore lint/suspicious/noConsole: no error-surfacing path exists for a fire-and-forget draft save.
852
- console.warn(
853
- `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.`,
854
- );
855
- return;
856
- }
857
- const passwordFieldNames = new Set(
858
- vm.sections.flatMap((section) =>
859
- section.kind === "fields"
860
- ? section.fields.filter((f) => f.format === "password").map((f) => f.field)
861
- : [],
862
- ),
863
- );
864
- // A password field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
865
- const draftValues = Object.fromEntries(
866
- Object.entries(controller.getSnapshot().values).filter(
867
- ([field]) => !passwordFieldNames.has(field),
868
- ),
869
- );
870
- void dispatcher
871
- .write(FORM_DRAFT_SAVE, {
872
- draftKey: key,
873
- values: draftValues,
874
- stepIndex,
875
- })
876
- .catch((err: unknown) => {
877
- // biome-ignore lint/suspicious/noConsole: fire-and-forget draft save must not red-screen
878
- console.warn("render-edit: draft save failed", err);
879
- });
880
- }
881
-
882
- // Next: scoped validate() on the current step's fields — errors stay
883
- // attached to the field and block the step transition. Extension steps
884
- // have no field scope here (they validate via the controlled-mode
885
- // controls.validate() API), so no call, no clearing of unrelated errors.
886
- function handleWizardNext(): void {
887
- const next = Math.min(currentStep + 1, lastStepIndex);
888
- hasNavigatedRef.current = true;
889
- // Locked state (#1896): `disabled` means "no input/no write", not "no
890
- // navigation" — a disabled wizard must still be steppable so Back/Next
891
- // reach every step. But neither validate() (would block on an empty
892
- // required field nobody can fill in) nor saveDraft() (would mint a
893
- // draftId / dispatch a write — exactly what `disabled` forbids) may run.
894
- if (disabled) {
895
- setRawStep(next);
896
- return;
897
- }
898
- const section = filteredSections[currentStep];
899
- const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
900
- // skip: the current step has field errors — no transition, no draft save.
901
- if (fieldNames.length > 0 && !controller.validate(fieldNames)) return;
902
- setRawStep(next);
903
- saveDraft(next);
904
- }
905
-
906
- function handleWizardBack(): void {
907
- handleWizardJumpBack(currentStep - 1);
908
- }
909
-
910
- // Only backwards: a forward jump would skip the per-step validate() gate.
911
- function handleWizardJumpBack(targetStep: number): void {
912
- if (targetStep >= currentStep) return;
913
- const previous = Math.max(targetStep, 0);
914
- hasNavigatedRef.current = true;
915
- if (disabled) {
916
- setRawStep(previous);
917
- return;
918
- }
919
- setRawStep(previous);
920
- saveDraft(previous);
921
- }
922
-
923
- async function discardDraft(): Promise<void> {
924
- // skip: this screen does not persist a draft.
925
- if (!draftEnabled || dispatcher === undefined) return;
926
- // A pending debounced patch-save must not fire after discard — it would
927
- // resurrect the draft it just deleted.
928
- if (draftSaveTimerRef.current !== null) {
929
- clearTimeout(draftSaveTimerRef.current);
930
- draftSaveTimerRef.current = null;
931
- }
932
- // skip: create-mode, no step change happened yet — no draftId was ever
933
- // minted, so no row exists to discard.
934
- if (draftKey === undefined) return;
935
- // Best-effort: a rejected write (e.g. network error) must not propagate
936
- // out of handleSubmit AFTER the entity write already succeeded — that
937
- // would break onSubmit/navigation/toast and risk a duplicate submit on
938
- // retry. An orphaned draft row is swept later by cleanup.job.ts. Local
939
- // cleanup below still runs on failure so this instance doesn't try to
940
- // resume a draft it already considers submitted.
941
- try {
942
- await dispatcher.write(FORM_DRAFT_DISCARD, { draftKey });
943
- } catch {
944
- // best-effort, see comment above.
945
- }
946
- // A successful submit ends this draftId's life — a subsequent create on
947
- // the same screen (new mount) must mint its own, not resume this one.
948
- if (isCreateMode) {
949
- draftStorage.clearDraftId(screen.id);
950
- mintedDraftIdRef.current = null;
951
- setDraftId(null);
952
- }
953
- }
954
-
955
- // Stepped modes (wizard, tabs): locates the first step whose "fields"
956
- // section contains one of the given field paths — matched on the path's
957
- // first segment, since `FieldIssue.path`/`snapshot.errors` keys can be
958
- // dotted (embedded-list rows, e.g. `tasks.2.title`) while a section's field
959
- // name is always the plain top-level name. Returns undefined for a
960
- // root-level issue (path `"(root)"`, see zod-bridge.ts) or one that matches
961
- // no rendered field — callers must NOT suppress the error banner in that
962
- // case, there is nowhere to jump the user to. Extension sections are not
963
- // jump targets by design: they validate through the controlled-mode
964
- // controls.validate() API and own no field specs to match against.
965
- function findFirstErroringStep(fieldPaths: readonly string[]): number | undefined {
966
- const erroredFields = new Set(fieldPaths.map((p) => p.split(".")[0]));
967
- const idx = filteredSections.findIndex(
968
- (s) => s.kind === "fields" && s.fields.some((f) => erroredFields.has(f.field)),
969
- );
970
- return idx === -1 ? undefined : idx;
971
- }
972
-
973
- async function handleSubmit(options?: { readonly saveNow?: boolean }): Promise<void> {
974
- // Enter in the active step triggers the native form submit (Next is
975
- // type="submit" for Enter support) — on intermediate steps that means
976
- // "Next", not "Save". Checked BEFORE the `disabled` guard below:
977
- // `disabled` means "no input/no write", not "no navigation" — a
978
- // disabled wizard must still be steppable (handleWizardNext itself
979
- // blocks validate()/saveDraft() while disabled). `saveNow` is the explicit
980
- // "Save and close" button and skips the step advance.
981
- if (isWizard && !isLastWizardStep && options?.saveNow !== true) {
982
- handleWizardNext();
983
- return;
984
- }
985
- // Locked state (#1896): the submit button is visibly disabled, but a
986
- // native form submit (Enter key) reaches this handler regardless of the
987
- // button's disabled attribute — block it here too, not just in the UI.
988
- if (disabled) return;
989
- // Sync guard — React state `isSubmitting` is too late for double-clicks
990
- // in the same tick (customSubmit has no submitInFlight of its own).
991
- if (isSubmittingRef.current) return;
992
- isSubmittingRef.current = true;
993
- setIsSubmitting(true);
994
- setExtensionErrorKey(null);
995
- try {
996
- // Extension-only: only a section is dirty, the main form is unchanged.
997
- // No entity write (would send an empty changes payload) — only
998
- // die Section-Handler laufen lassen.
999
- if (snapshot.isUnchanged && extensionDirty) {
1000
- // Same discard as the main path: an extension-only save is still a
1001
- // successful submit, so the draft must not survive it.
1002
- if (await persistExtensions()) {
1003
- submittedRef.current = true;
1004
- await discardDraft();
1005
- }
1006
- return;
1007
- }
1008
- let result: SubmitResult<unknown>;
1009
- if (customSubmit !== undefined) {
1010
- // customSubmit path (e.g. configEdit, which fires a separate write
1011
- // per field). Client-side validation first, then
1012
- // an den Caller; on-success rebased der Form-State explizit
1013
- // because controller.submit() normally does this itself and
1014
- // without customSubmit's help the controller knows nothing about
1015
- // erfolgreichen Submit (isUnchanged blieb sonst false).
1016
- //
1017
- // WICHTIG: snapshot direkt vom Controller holen statt aus
1018
- // React state. On rapid fill→click, React batching may not have
1019
- // committed input state updates yet when the submit click fires.
1020
- // handleSubmit's closure would then run with
1021
- // stale snapshot.changes={} laufen, customSubmit fired keine
1022
- // Writes, returnt success, Form rebase → User glaubt "saved"
1023
- // aber gar nichts ist passiert. controller.getSnapshot() ist
1024
- // immer aktuell — der Controller ist die Source-of-Truth, die
1025
- // React state is only a mirror for rendering.
1026
- const valid = controller.validate(scopeFieldNames);
1027
- if (!valid) {
1028
- // Field order matters: validationBlocked:true is its own
1029
- // variant in the SubmitResult union (NOT mixed with data/error);
1030
- // TS only narrows cleanly without a discriminator fight.
1031
- const blocked: SubmitResult<unknown> = {
1032
- validationBlocked: true,
1033
- isSuccess: false,
1034
- };
1035
- result = blocked;
1036
- } else {
1037
- result = await customSubmit(controller.getSnapshot());
1038
- if (result.isSuccess) controller.rebase();
1039
- }
1040
- } else {
1041
- result = await controller.submit();
1042
- }
1043
- // Form-level errors (without field-level details) go to the banner.
1044
- // Field errors flow via snapshot.errors into the individual fields.
1045
- let extensionsPersisted = true;
1046
- if (result.isSuccess) {
1047
- setFormError(null);
1048
- // `isNoOp: true` (payloadMode "changes", pre-filled form submitted
1049
- // untouched) means controller.submit() never called dispatcher.write
1050
- // — nothing to discard, and discarding here would delete a draft the
1051
- // form never persisted (fw#1978). Awaited, not fire-and-forget:
1052
- // `onSubmit` typically navigates away and unmounts this form, which
1053
- // would abort an in-flight discard and leave the draft behind after
1054
- // a successful submit.
1055
- if (result.isNoOp !== true) {
1056
- submittedRef.current = true;
1057
- await discardDraft();
1058
- }
1059
- extensionsPersisted = await persistExtensions();
1060
- } else if (result.validationBlocked) {
1061
- // Root-level `.refine()`/cross-field issues from controller.validate()
1062
- // — same "jump to the erroring step" treatment as a server field
1063
- // error below, sourced from the freshly-validated snapshot.
1064
- if (isStepped) {
1065
- const step = findFirstErroringStep(Object.keys(controller.getSnapshot().errors));
1066
- if (step !== undefined) setRawStep(step);
1067
- }
1068
- } else {
1069
- const fieldIssues = result.error.details?.fields ?? [];
1070
- const issuePaths = fieldIssues.map((i) => i.path);
1071
- // A server field error on a step the form isn't currently showing is
1072
- // otherwise invisible — jump to the first step that contains one of
1073
- // the errored fields. For tabs this is what keeps the submit from
1074
- // blocking in silence, the failure mode the boot-validator used to
1075
- // prevent by rejecting the layout outright (fw#3134). An issue no
1076
- // rendered field can show (`version`, `id`, a root-level refine, a
1077
- // hidden field) keeps the banner, jump or not.
1078
- if (isStepped) {
1079
- const step = findFirstErroringStep(issuePaths);
1080
- if (step !== undefined) setRawStep(step);
1081
- }
1082
- setFormError(
1083
- hasIssueWithoutRenderedField(issuePaths, filteredSections) ? result.error : null,
1084
- );
1085
- }
1086
- // skip: on entity-success-but-extension-failure the extension-error
1087
- // banner is showing — don't notify (the caller navigates away on success
1088
- // and would unmount it before the user sees the failure).
1089
- if (shouldNotifyCaller(result, extensionsPersisted)) onSubmit?.(result);
1090
- } finally {
1091
- isSubmittingRef.current = false;
1092
- setIsSubmitting(false);
1093
- }
1094
- }
1095
- handleSubmitRef.current = handleSubmit;
1096
-
1097
- // Screen forms (entityEdit/actionForm filling the shell height) drop the card;
1098
- // the title then lives in the shell header when the shell offers a slot.
1099
- const isScreenForm =
1100
- fillScreenHeight === true &&
1101
- hideSectionTitles !== true &&
1102
- headerRegion === undefined &&
1103
- !insideDrawer;
1104
- const titleInShell =
1105
- PageHeader !== undefined &&
1106
- pageHeaderSlotAvailable &&
1107
- hideSectionTitles !== true &&
1108
- headerRegion === undefined;
1109
- const menuPageHeaderActions = titleInShell && isScreenForm && ActionOverflowMenu !== undefined;
1110
- const cancelInHeaderMenu = menuPageHeaderActions && isWizard && onCancel !== undefined;
1111
- // Two groups — wizard navigation on the right/top, record actions on the
1112
- // left/below; destructive action sits outermost, farthest from the target
1113
- // action.
1114
- const hasSecondaryFormActions =
1115
- (!menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined)) ||
1116
- (actions !== undefined && actions.length > 0) ||
1117
- (onCancel !== undefined && !cancelInHeaderMenu);
1118
- // Teil-C collapse rule: driven by the custom `actions` group only — a
1119
- // resolved true also folds the Copy-Link button (same visual group in the
1120
- // footer), but never Delete/Cancel, which keep their text regardless.
1121
- const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
1122
- const headerMenuItems: readonly ActionMenuItemSpec[] = menuPageHeaderActions
1123
- ? [
1124
- ...(onCopyLink !== undefined
1125
- ? [
1126
- {
1127
- id: "copy-link",
1128
- label: translate(
1129
- linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink",
1130
- ),
1131
- icon: "link" as const,
1132
- onSelect: () => {
1133
- void Promise.resolve(onCopyLink()).then(() => setLinkCopied(true));
1134
- },
1135
- },
1136
- ]
1137
- : []),
1138
- ...(cancelInHeaderMenu
1139
- ? [
1140
- {
1141
- id: "cancel",
1142
- label: translate("kumiko.actions.cancel"),
1143
- icon: "x" as const,
1144
- onSelect: () => onCancel(),
1145
- },
1146
- ]
1147
- : []),
1148
- ...(onDelete !== undefined
1149
- ? [
1150
- {
1151
- id: "delete",
1152
- label: translate("kumiko.actions.delete"),
1153
- icon: "trash" as const,
1154
- variant: "danger" as const,
1155
- onSelect: () => setConfirmDeleteOpen(true),
1156
- },
1157
- ]
1158
- : []),
1159
- ]
1160
- : [];
1161
- const secondaryFormActions = (
1162
- <>
1163
- {onDelete !== undefined && !menuPageHeaderActions && (
1164
- <Button
1165
- type="button"
1166
- variant="danger-ghost"
1167
- icon="trash"
1168
- testId="render-edit-delete"
1169
- disabled={disabled}
1170
- onClick={() => setConfirmDeleteOpen(true)}
1171
- >
1172
- {translate("kumiko.actions.delete")}
1173
- </Button>
1174
- )}
1175
- {onCopyLink !== undefined && !menuPageHeaderActions && (
1176
- <Button
1177
- type="button"
1178
- variant={iconOnlyMidActions ? "secondary" : "link"}
1179
- icon={linkCopied ? "check" : "link"}
1180
- testId="render-edit-copy-link"
1181
- {...(iconOnlyMidActions && {
1182
- size: "icon" as const,
1183
- ariaLabel: translate(
1184
- linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink",
1185
- ),
1186
- })}
1187
- onClick={async () => {
1188
- await onCopyLink();
1189
- setLinkCopied(true);
1190
- }}
1191
- >
1192
- {iconOnlyMidActions
1193
- ? null
1194
- : translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
1195
- </Button>
1196
- )}
1197
- {actions?.map((action) => (
1198
- <RenderEditActionButton
1199
- key={action.id}
1200
- action={action}
1201
- iconOnly={iconOnlyMidActions}
1202
- Button={Button}
1203
- Dialog={Dialog}
1204
- onError={setActionError}
1205
- />
1206
- ))}
1207
- {onCancel !== undefined && !cancelInHeaderMenu && (
1208
- <Button
1209
- type="button"
1210
- variant="secondary"
1211
- onClick={() => onCancel()}
1212
- testId="render-edit-cancel"
1213
- >
1214
- {translate("kumiko.actions.cancel")}
1215
- </Button>
1216
- )}
1217
- </>
1218
- );
1219
- const headerSlot = screen.slots?.header;
1220
- const headerSlotMount =
1221
- headerSlot !== undefined ? (
1222
- <EditSlotMount
1223
- slot={headerSlot}
1224
- slotName="header"
1225
- screenId={screen.id}
1226
- entityName={vm.entityName}
1227
- entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
1228
- values={snapshot.values}
1229
- />
1230
- ) : undefined;
1231
- // A function headerRegion places the slot itself (projectionDetail puts it
1232
- // into its header card next to the actions); otherwise the slot renders
1233
- // above the caller's own headerRegion.
1234
- const formHeaderRegion =
1235
- typeof headerRegion === "function" ? (
1236
- headerRegion(headerSlotMount)
1237
- ) : headerSlotMount !== undefined ? (
1238
- <>
1239
- {headerSlotMount}
1240
- {headerRegion}
1241
- </>
1242
- ) : (
1243
- headerRegion
1244
- );
1245
- const titleActionSlot = screen.slots?.titleAction;
1246
- const titleActionMount =
1247
- titleActionSlot !== undefined ? (
1248
- <EditSlotMount
1249
- slot={titleActionSlot}
1250
- slotName="titleAction"
1251
- screenId={screen.id}
1252
- entityName={vm.entityName}
1253
- entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
1254
- values={snapshot.values}
1255
- />
1256
- ) : undefined;
1257
- // A projectionDetail's synthesized entity is empty when every section is an
1258
- // extension or relatedList; that is not a fieldless submit form, so it must not
1259
- // draw a no-op Save. Extension registrations still get their Save, since
1260
- // persistExtensions() runs on the customSubmit path too.
1261
- const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
1262
- const showsSubmit = isFormEditable || hasExtensionRegistrations || isFieldlessSubmitForm;
1263
- const footerSlot = screen.slots?.footer;
1264
- // Mirrors every branch inside formActions below — without this guard
1265
- // DefaultForm renders an empty footer strip (border + padding, no content)
1266
- // on read-only detail screens, since `actions` would otherwise always be
1267
- // a defined (if empty) fragment.
1268
- const hasFormActions =
1269
- (isWizard && currentStep > 0) ||
1270
- (isWizard && !isLastWizardStep) ||
1271
- (showsSubmit && (!isWizard || isLastWizardStep)) ||
1272
- footerSlot !== undefined;
1273
- const nextStepTitle = isWizard ? filteredSections[currentStep + 1]?.title : undefined;
1274
- const formActions = (
1275
- <>
1276
- {isWizard && currentStep > 0 && (
1277
- <Button
1278
- type="button"
1279
- variant="secondary"
1280
- icon="chevron-left"
1281
- onClick={handleWizardBack}
1282
- testId="render-edit-wizard-back"
1283
- >
1284
- {translate("kumiko.actions.back")}
1285
- </Button>
1286
- )}
1287
- {footerSlot !== undefined && (
1288
- <EditSlotMount
1289
- slot={footerSlot}
1290
- slotName="footer"
1291
- screenId={screen.id}
1292
- entityName={vm.entityName}
1293
- entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
1294
- values={snapshot.values}
1295
- hasUnsavedChanges={snapshot.isDirty || extensionDirty}
1296
- wizardStep={isWizard ? { index: currentStep, isLast: isLastWizardStep } : undefined}
1297
- {...(screen.slots?.footerPrimary === true && { [STICKY_PRIMARY_ACTION_PROP]: true })}
1298
- />
1299
- )}
1300
- {isWizard && !isLastWizardStep && showsSubmit && !isCreateMode && (
1301
- <Button
1302
- type="button"
1303
- variant="ghost"
1304
- disabled={isSubmitting || disabled}
1305
- onClick={() => void handleSubmit({ saveNow: true })}
1306
- testId="render-edit-wizard-save-close"
1307
- {...{ [STICKY_PRIMARY_ACTION_PROP]: true }}
1308
- >
1309
- {translate("kumiko.wizard.save-and-close")}
1310
- </Button>
1311
- )}
1312
- {isWizard && !isLastWizardStep && (
1313
- <Button
1314
- type="submit"
1315
- variant="primary"
1316
- iconEnd="chevron-right"
1317
- testId="render-edit-wizard-next"
1318
- >
1319
- {nextStepTitle !== undefined
1320
- ? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
1321
- : translate("kumiko.actions.next")}
1322
- </Button>
1323
- )}
1324
- {showsSubmit && (!isWizard || isLastWizardStep) && (
1325
- <Button
1326
- type="submit"
1327
- disabled={
1328
- (snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
1329
- isSubmitting ||
1330
- disabled
1331
- }
1332
- loading={isSubmitting}
1333
- variant={submitVariant ?? "primary"}
1334
- testId="render-edit-submit"
1335
- >
1336
- {translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save"))}
1337
- </Button>
1338
- )}
1339
- </>
1340
- );
1341
-
1342
- // Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
1343
- // screen:<id>.<mode>.title / .<mode>.subtitle
1344
- // Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
1345
- // title falls back to screenId; subtitle falls back to screen.description,
1346
- // itself run through translate() so an i18n key (the established
1347
- // convention for description, e.g. bundled-features) resolves instead of
1348
- // showing the raw key — translate() returns the input unchanged for plain
1349
- // prose, same as it does for an unknown key (fw#2723 review). Same slot
1350
- // section.description already fills for a section. Falls back to
1351
- // undefined (no subtitle) when neither is set.
1352
- const isCreate = (() => {
1353
- const id = resolveExtensionEntityId(entityIdProp, vm.id);
1354
- return id == null || id === "";
1355
- })();
1356
- const formMode = isCreate ? "create" : "edit";
1357
- const resolveScreenText = (suffix: string): string | undefined => {
1358
- for (const key of [
1359
- `screen:${screen.id}.${formMode}.${suffix}`,
1360
- `screen:${screen.id}.${suffix}`,
1361
- ]) {
1362
- const value = translate(key);
1363
- if (value !== key) return value;
1364
- }
1365
- return undefined;
1366
- };
1367
- const formTitle = resolveScreenText("title") ?? screen.id;
1368
- // screen.description is head-card copy, not tab content — in tabs mode
1369
- // (hideSectionTitles) the head card already carries title/subtitle/status.
1370
- const formSubtitle =
1371
- hideSectionTitles !== true
1372
- ? (resolveScreenText("subtitle") ??
1373
- (screen.description !== undefined ? translate(screen.description) : undefined))
1374
- : undefined;
1375
-
1376
- const usesFieldFlow = isScreenForm || insideDrawer;
1377
- const allRequiredHint = (
1378
- <Text variant="muted" testId="render-edit-all-required-hint">
1379
- {translate("kumiko.form.all-required")}
1380
- </Text>
1381
- );
1382
- const stepRail = isWizard && isScreenForm && StepBar !== undefined;
1383
- const changedFieldCount = isCreate ? 0 : Object.keys(snapshot.changes).length;
1384
- const unsavedCount =
1385
- isCreate || !isScreenForm
1386
- ? 0
1387
- : changedFieldCount > 0
1388
- ? changedFieldCount
1389
- : extensionDirty
1390
- ? 1
1391
- : 0;
1392
- const sectionNavItems: readonly FormSectionNavItem[] =
1393
- isScreenForm && !isStepped
1394
- ? filteredSections.flatMap((section, index) =>
1395
- section.kind !== "extension" &&
1396
- section.kind !== "relatedList" &&
1397
- section.kind !== "writeForm" &&
1398
- section.visible &&
1399
- section.title !== undefined &&
1400
- section.title !== formTitle
1401
- ? [{ id: formSectionDomId(index), title: section.title }]
1402
- : [],
1403
- )
1404
- : [];
1405
- const compactStepLabel = (() => {
1406
- const currentTitle = filteredSections[currentStep]?.title;
1407
- return currentTitle !== undefined
1408
- ? translate("kumiko.wizard.step-with-title", {
1409
- current: currentStep + 1,
1410
- total: lastStepIndex + 1,
1411
- title: currentTitle,
1412
- })
1413
- : translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
1414
- })();
1415
-
1416
- return (
1417
- <ExtensionFormRegistryProvider value={extensionFormRegistry}>
1418
- {titleInShell && (
1419
- <PageHeader
1420
- title={formTitle}
1421
- {...(headerMenuItems.length > 0 &&
1422
- ActionOverflowMenu !== undefined && {
1423
- actions: (
1424
- <ActionOverflowMenu
1425
- label={translate("kumiko.list.row-actions.more")}
1426
- items={headerMenuItems}
1427
- testId="render-edit-header-menu"
1428
- />
1429
- ),
1430
- })}
1431
- />
1432
- )}
1433
- <Form
1434
- onSubmit={() => void handleSubmit()}
1435
- {...(hideSectionTitles !== true && !titleInShell && { title: formTitle })}
1436
- {...(formSubtitle !== undefined && !stepRail && { subtitle: formSubtitle })}
1437
- {...(isScreenForm && { screenForm: true })}
1438
- {...(unsavedCount > 0 && { unsavedCount })}
1439
- {...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems })}
1440
- {...(stepRail && {
1441
- sideRail: (
1442
- <StepBar
1443
- steps={filteredSections.map((section) => section.title ?? "")}
1444
- currentIndex={currentStep}
1445
- compactLabel={compactStepLabel}
1446
- onStepSelect={handleWizardJumpBack}
1447
- orientation="vertical"
1448
- heading={translate("kumiko.wizard.step", {
1449
- current: currentStep + 1,
1450
- total: lastStepIndex + 1,
1451
- })}
1452
- {...(formSubtitle !== undefined && { description: formSubtitle })}
1453
- testId="render-edit-wizard-steps"
1454
- compactTestId="render-edit-wizard-step-label"
1455
- />
1456
- ),
1457
- })}
1458
- {...(hideActions !== true && hasFormActions && { actions: formActions })}
1459
- {...(hideActions !== true &&
1460
- hasSecondaryFormActions && { secondaryActions: secondaryFormActions })}
1461
- testId="render-edit-form"
1462
- stickyActions={isWizard || fillScreenHeight === true}
1463
- {...(screen.layout.width !== undefined && { width: screen.layout.width })}
1464
- {...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion })}
1465
- {...(titleActionMount !== undefined && { titleAction: titleActionMount })}
1466
- {...(fillHeight && { fillHeight })}
1467
- {...(hideSectionTitles === true && { chromeless: true })}
1468
- {...(summary !== undefined && { summary })}
1469
- >
1470
- <AllFieldsRequiredProvider value={showsAllRequiredHint}>
1471
- {showsAllRequiredHint && !insideDrawer && allRequiredHint}
1472
- {draftCandidates !== null && (
1473
- <Banner
1474
- variant="info"
1475
- testId="render-edit-draft-picker"
1476
- actions={[
1477
- ...draftCandidates.map((candidate) => (
1478
- <Button
1479
- key={candidate.id}
1480
- type="button"
1481
- variant="link"
1482
- onClick={() => adoptDraft(candidate)}
1483
- testId={`render-edit-draft-pick-${candidate.id}`}
1484
- >
1485
- {formatWhen(candidate.savedAt)}
1486
- </Button>
1487
- )),
1488
- <Button
1489
- key="start-new"
1490
- type="button"
1491
- variant="link"
1492
- onClick={() => setDraftCandidates(null)}
1493
- testId="render-edit-draft-start-new"
1494
- >
1495
- {translate("kumiko.form.draft.start-new")}
1496
- </Button>,
1497
- ]}
1498
- >
1499
- <Text>
1500
- {translate(
1501
- draftCandidates.length === 1
1502
- ? "kumiko.form.draft.resume-single"
1503
- : "kumiko.form.draft.resume-multiple",
1504
- )}
1505
- </Text>
1506
- </Banner>
1507
- )}
1508
- {isWizard && !stepRail && (
1509
- <>
1510
- {Progress !== undefined && (
1511
- <Progress
1512
- value={(currentStep + 1) / (lastStepIndex + 1)}
1513
- testId="render-edit-wizard-progress"
1514
- />
1515
- )}
1516
- {/* No StepBar registered → keep the plain label RenderEdit always had (additive rollout, see CorePrimitives.StepBar). */}
1517
- {StepBar === undefined ? (
1518
- <Text variant="small" testId="render-edit-wizard-step-label">
1519
- {compactStepLabel}
1520
- </Text>
1521
- ) : (
1522
- <StepBar
1523
- steps={filteredSections.map((section) => section.title ?? "")}
1524
- currentIndex={currentStep}
1525
- compactLabel={compactStepLabel}
1526
- onStepSelect={handleWizardJumpBack}
1527
- testId="render-edit-wizard-steps"
1528
- compactTestId="render-edit-wizard-step-label"
1529
- />
1530
- )}
1531
- </>
1532
- )}
1533
- {isTabs &&
1534
- (() => {
1535
- // No Tabs primitive registered → every section renders stacked
1536
- // (stepHidden below is gated on the same condition), which is the
1537
- // single-layout behaviour. Degrading to one long form beats
1538
- // hiding sections behind a strip that never drew.
1539
- if (Tabs === undefined) return null;
1540
- return (
1541
- <Tabs
1542
- testId="render-edit-tabs"
1543
- items={filteredSections.map((section, index) => ({
1544
- id: tabIdAt(section, index),
1545
- label: section.title ?? "",
1546
- }))}
1547
- activeId={tabIdAt(filteredSections[currentStep], currentStep)}
1548
- onSelect={(id) => {
1549
- const index = filteredSections.findIndex((s, i) => tabIdAt(s, i) === id);
1550
- // skip: the strip reported an id no section owns.
1551
- if (index === -1) return;
1552
- setRawStep(index);
1553
- }}
1554
- />
1555
- );
1556
- })()}
1557
- {filteredSections.map((section: EditSectionViewModel, sectionIndex: number) => {
1558
- // turns this section's own `actions` (RowAction[]) into
1559
- // already-bound buttons via the caller-supplied builder, rendered
1560
- // through the same RenderEditActionButton the top-level `actions`
1561
- // prop uses (same style/confirm/icon/error handling).
1562
- const sectionActions = section.actions;
1563
- const sectionActionsEl: ReactNode =
1564
- sectionActions !== undefined && sectionActions.length > 0
1565
- ? buildSectionActions?.(sectionActions)?.map((action) => (
1566
- <RenderEditActionButton
1567
- key={action.id}
1568
- action={action}
1569
- Button={Button}
1570
- Dialog={Dialog}
1571
- onError={setActionError}
1572
- />
1573
- ))
1574
- : undefined;
1575
- // Wizard steps and tabs stay mounted while off-screen (native
1576
- // `hidden`, not unmounted) so an extension section's submit-registry
1577
- // entry (useExtensionFormSubmit → registry.remove on unmount)
1578
- // survives navigating past its step — otherwise Finish only ran the
1579
- // last mounted step's handler and silently dropped earlier steps'
1580
- // writes. For tabs this is also what makes ONE submit cover every
1581
- // tab, including tabs the user never opened (fw#3134).
1582
- const stepHidden = isStepped && sectionIndex !== currentStep;
1583
- const wrapWizardStep = (key: string, el: ReactNode): ReactNode => {
1584
- if (!isStepped) return el;
1585
- if (WizardStepGroup === undefined) {
1586
- throw new Error(
1587
- `RenderEdit: ${screen.layout.mode} layout requires primitives.WizardStepGroup, but none is registered.`,
1588
- );
1589
- }
1590
- return (
1591
- <WizardStepGroup
1592
- key={key}
1593
- hidden={stepHidden}
1594
- {...(hideSectionTitles === true && { inset: true })}
1595
- >
1596
- {el}
1597
- </WizardStepGroup>
1598
- );
1599
- };
1600
- // Off-screen wizard steps stay mounted (see comment above) but must
1601
- // not participate in native constraint validation, or the Next
1602
- // button's `type="submit"` triggers the browser's full-form check
1603
- // — including required fields on unvisited steps, which are not
1604
- // focusable while hidden, so the wizard silently stops navigating
1605
- // (only a console warning, no visible error). A plain `hidden`
1606
- // attribute does NOT bar descendants from constraint validation on
1607
- // web — see WizardStepGroupProps for what implementations must
1608
- // guarantee.
1609
- if (section.kind === "extension") {
1610
- const mount = (
1611
- <ExtensionSectionMount
1612
- key={section.title}
1613
- section={section}
1614
- entityName={vm.entityName}
1615
- entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
1616
- initialValues={extensionInitialValues}
1617
- values={snapshot.values}
1618
- // @cast-boundary form-values: ExtensionSectionProps is not generic
1619
- // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
1620
- patch={
1621
- patchAndScheduleDraftSave as (
1622
- partial: Readonly<Record<string, unknown>>,
1623
- ) => void
1624
- }
1625
- validate={scopedValidate}
1626
- hideTitle={hideSectionTitles}
1627
- // Tabs mode: actions move to the outer Card below — the inner
1628
- // Section is flattened (rendered inside this component's own
1629
- // Form) and would otherwise duplicate them.
1630
- {...(hideSectionTitles !== true &&
1631
- sectionActionsEl !== undefined && { actions: sectionActionsEl })}
1632
- />
1633
- );
1634
- // Section always flattens to a borderless divider inside this
1635
- // component's own <Form>, so tabs mode frames it with Card
1636
- // instead — same reason the fields-section tabs branch below
1637
- // stays on Card. No title here: the tab strip already labels the
1638
- // panel (matches the non-tabs Section branch above via hideTitle).
1639
- // testId stays only on the inner Section — this outer Card is
1640
- // purely chrome, giving it the same testId would register two
1641
- // elements under one id.
1642
- const wrapped =
1643
- hideSectionTitles === true ? (
1644
- <Card
1645
- key={section.title}
1646
- {...(sectionActionsEl !== undefined && {
1647
- slots: { headerActions: sectionActionsEl },
1648
- })}
1649
- >
1650
- {mount}
1651
- </Card>
1652
- ) : (
1653
- mount
1654
- );
1655
- return wrapWizardStep(section.title, wrapped);
1656
- }
1657
- if (section.kind === "relatedList") {
1658
- // parentId is the displayed record's id — without it there's no
1659
- // parent row whose related rows could be queried (fw#2166).
1660
- // Rejected at boot in wizard layouts, so no WizardStepGroup here.
1661
- const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
1662
- if (parentId === null) return null;
1663
- const mount = (
1664
- <RelatedListSection
1665
- key={section.title}
1666
- section={section}
1667
- parentId={parentId}
1668
- // @cast-boundary form-values: TValues ist strukturell ein Record.
1669
- record={snapshot.values as unknown as Readonly<Record<string, unknown>>}
1670
- featureName={featureName}
1671
- translate={translate}
1672
- hideTitle={hideSectionTitles}
1673
- {...(fillScreenHeight === true && { grow: true })}
1674
- // Tabs mode: actions move to the outer Card below — the
1675
- // hideTitle branch of RelatedListSection has no title row to
1676
- // render them into.
1677
- {...(hideSectionTitles !== true &&
1678
- sectionActionsEl !== undefined && { actions: sectionActionsEl })}
1679
- {...(onRelatedListDrawerAction !== undefined && {
1680
- onOpenDrawer: onRelatedListDrawerAction,
1681
- })}
1682
- />
1683
- );
1684
- // Tabs mode: the table runs full-bleed under the tab strip (board
1685
- // layout); the toolbar buttons live in the list's own toolbar.
1686
- return hideSectionTitles === true ? (
1687
- <div
1688
- key={section.title}
1689
- className={fillScreenHeight === true ? "flex min-h-0 flex-1 flex-col" : undefined}
1690
- >
1691
- {mount}
1692
- </div>
1693
- ) : (
1694
- mount
1695
- );
1696
- }
1697
- if (section.kind === "writeForm") {
1698
- // Own submit button + dispatcher call, entirely independent of
1699
- // this screen's (nonexistent, on projectionDetail) form submit —
1700
- // rejected at boot in wizard layouts, so no WizardStepGroup here.
1701
- return (
1702
- <WriteFormSection
1703
- key={section.title ?? `write-form-${sectionIndex}`}
1704
- section={section}
1705
- featureName={featureName}
1706
- translate={translate}
1707
- hideTitle={hideSectionTitles}
1708
- onSubmitted={() => onReload?.()}
1709
- {...(sectionActionsEl !== undefined && { actions: sectionActionsEl })}
1710
- />
1711
- );
1712
- }
1713
- if (!section.visible) return null;
1714
- // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
1715
- const sectionKey = section.title ?? `section-${sectionIndex}`;
1716
- const renderFieldGrid = (
1717
- fields: readonly EditFieldViewModel[],
1718
- columns: number,
1719
- gridKey: string,
1720
- ): ReactNode => (
1721
- <Grid key={gridKey} columns={columns} {...(usesFieldFlow && { flow: true })}>
1722
- {fields.map((field: EditFieldViewModel) => (
1723
- <GridCellForField
1724
- key={field.field}
1725
- field={disabled ? { ...field, readOnly: true } : field}
1726
- columns={columns}
1727
- {...(usesFieldFlow && { flow: true })}
1728
- issues={snapshot.errors[field.field]}
1729
- onChange={(v) => {
1730
- (controller.setField as (k: string, v: unknown) => void)(field.field, v);
1731
- }}
1732
- GridCell={GridCell}
1733
- featureName={featureName}
1734
- {...(labelAppendix !== undefined && {
1735
- labelAppendix: labelAppendix(field.field),
1736
- })}
1737
- {...(fieldAppendix !== undefined && {
1738
- fieldAppendix: fieldAppendix(field.field),
1739
- })}
1740
- allIssues={snapshot.errors}
1741
- valueDisplay={valueDisplay}
1742
- row={snapshot.values}
1743
- {...(!isCreate && field.field in snapshot.changes && { changed: true })}
1744
- />
1745
- ))}
1746
- </Grid>
1747
- );
1748
- // Tabs mode renders one or more titled cards, so this card's actual
1749
- // heading doesn't visually duplicate the short Tab strip label.
1750
- // Stays `Card`, not `Section` — Section always flattens to a
1751
- // borderless divider when rendered inside this component's own
1752
- // `<Form>` (InsideFormContext), even with `chromeless` set, so it
1753
- // cannot stand in for the tabs-mode card here.
1754
- if (hideSectionTitles === true) {
1755
- if (section.groups !== undefined) {
1756
- const groupsGrid = (
1757
- <Grid key={`${sectionKey}-groups`} columns={1}>
1758
- {section.groups.map((group, groupIndex) => (
1759
- <Card
1760
- key={group.title}
1761
- slots={{ title: group.title }}
1762
- testId={`section-${sectionKey}-group-${groupIndex}`}
1763
- >
1764
- {renderFieldGrid(
1765
- group.fields,
1766
- group.columns,
1767
- `${sectionKey}-group-${groupIndex}-grid`,
1768
- )}
1769
- </Card>
1770
- ))}
1771
- </Grid>
1772
- );
1773
- // `actions` on a `groups` section has no group of its own to
1774
- // sit in and would need an extra title-less Card around the
1775
- // group cards (Card-in-Card) — the boot-validator rejects that
1776
- // combination in tabs mode, so `sectionActionsEl` is always
1777
- // undefined here at runtime.
1778
- return wrapWizardStep(sectionKey, groupsGrid);
1779
- }
1780
- // No section title here (fw#3218) — the Tab strip right above
1781
- // already names this panel, so a repeated Card title would just
1782
- // duplicate it. The title row only exists to carry actions, and
1783
- // then it carries only the actions, never the title text.
1784
- const cardEl = (
1785
- <Card
1786
- key={sectionKey}
1787
- {...(sectionActionsEl !== undefined && {
1788
- slots: { headerActions: sectionActionsEl },
1789
- })}
1790
- testId={`section-${sectionKey}`}
1791
- >
1792
- {renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
1793
- </Card>
1794
- );
1795
- return wrapWizardStep(sectionKey, cardEl);
1796
- }
1797
- // Suppress the section header when it would just repeat the form
1798
- // title verbatim (typical for single-section actionForms), or the
1799
- // tab label right above it. Without a Tabs primitive the sections
1800
- // render stacked, and then the titles are what tells them apart.
1801
- const repeatsTabLabel = isTabs && isStepped;
1802
- const sectionTitle =
1803
- section.title === formTitle || repeatsTabLabel ? undefined : section.title;
1804
- const sectionEl = (
1805
- <Section
1806
- key={sectionKey}
1807
- {...(sectionNavItems.some((item) => item.id === formSectionDomId(sectionIndex)) && {
1808
- id: formSectionDomId(sectionIndex),
1809
- })}
1810
- {...(sectionTitle !== undefined && { title: sectionTitle })}
1811
- {...(section.description !== undefined && { subtitle: section.description })}
1812
- {...(section.icon !== undefined && { icon: section.icon })}
1813
- {...(sectionActionsEl !== undefined && { actions: sectionActionsEl })}
1814
- testId={`section-${sectionKey}`}
1815
- >
1816
- {showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint}
1817
- {renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
1818
- </Section>
1819
- );
1820
- return wrapWizardStep(sectionKey, sectionEl);
1821
- })}
1822
- {formError !== null && (
1823
- <Banner
1824
- variant="error"
1825
- testId="render-edit-form-error"
1826
- actions={
1827
- formError.code === "version_conflict" && onReload !== undefined ? (
1828
- <Button
1829
- variant="secondary"
1830
- onClick={() => {
1831
- onReload();
1832
- setFormError(null);
1833
- }}
1834
- testId="render-edit-form-error-reload"
1835
- >
1836
- {translate("kumiko.actions.reload")}
1837
- </Button>
1838
- ) : undefined
1839
- }
1840
- >
1841
- <Text testId="render-edit-form-error-key">{translate(formError.i18nKey)}</Text>
1842
- </Banner>
1843
- )}
1844
- {extensionErrorKey !== null && (
1845
- <Banner variant="error" testId="render-edit-extension-error">
1846
- <Text testId="render-edit-extension-error-key">{translate(extensionErrorKey)}</Text>
1847
- </Banner>
1848
- )}
1849
- {actionError !== null && (
1850
- <Banner variant="error" testId="render-edit-action-error">
1851
- {actionError}
1852
- </Banner>
1853
- )}
1854
- {onDelete !== undefined && (
1855
- <Dialog
1856
- open={confirmDeleteOpen}
1857
- onOpenChange={setConfirmDeleteOpen}
1858
- title={translate("kumiko.actions.delete-confirm")}
1859
- confirmLabel={translate("kumiko.actions.delete")}
1860
- variant="danger"
1861
- onConfirm={async () => {
1862
- await onDelete();
1863
- }}
1864
- testId="render-edit-delete-dialog"
1865
- />
1866
- )}
1867
- </AllFieldsRequiredProvider>
1868
- </Form>
1869
- </ExtensionFormRegistryProvider>
1870
- );
1871
- }