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