@cosmicdrift/kumiko-renderer 0.384.0 → 0.385.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/app/action-form-body.d.ts +50 -0
  2. package/dist/app/action-form-body.js +275 -0
  3. package/dist/app/client-actions.js +2 -2
  4. package/dist/app/config-edit-body.d.ts +9 -0
  5. package/dist/app/config-edit-body.js +340 -0
  6. package/dist/app/entity-edit-prefill.d.ts +27 -0
  7. package/dist/app/entity-edit-prefill.js +377 -0
  8. package/dist/app/entity-edit-screen.d.ts +11 -0
  9. package/dist/app/entity-edit-screen.js +395 -0
  10. package/dist/app/entity-list-screen.d.ts +10 -0
  11. package/dist/app/entity-list-screen.js +547 -0
  12. package/dist/app/kumiko-screen.d.ts +1 -36
  13. package/dist/app/kumiko-screen.js +16 -2855
  14. package/dist/app/projection-detail-body.d.ts +12 -0
  15. package/dist/app/projection-detail-body.js +464 -0
  16. package/dist/app/projection-detail-shim.d.ts +1 -1
  17. package/dist/app/projection-detail-shim.js +5 -1
  18. package/dist/app/projection-list-body.d.ts +10 -0
  19. package/dist/app/projection-list-body.js +245 -0
  20. package/dist/app/projection-list-shim.d.ts +2 -1
  21. package/dist/app/projection-list-shim.js +4 -0
  22. package/dist/app/reference-facet-bridge.js +2 -2
  23. package/dist/app/row-actions.js +3 -4
  24. package/dist/app/screen-shared.d.ts +72 -0
  25. package/dist/app/screen-shared.js +325 -0
  26. package/dist/app/secret-mint-body.js +3 -3
  27. package/dist/components/edit-mounts.d.ts +33 -0
  28. package/dist/components/edit-mounts.js +53 -0
  29. package/dist/components/error-boundary.js +2 -2
  30. package/dist/components/reference-create-dialog.js +1 -1
  31. package/dist/components/related-list-section.js +2 -2
  32. package/dist/components/render-edit-helpers.d.ts +29 -0
  33. package/dist/components/render-edit-helpers.js +121 -0
  34. package/dist/components/render-edit.d.ts +1 -4
  35. package/dist/components/render-edit.js +41 -443
  36. package/dist/components/render-field.js +3 -4
  37. package/dist/components/render-list.js +2 -2
  38. package/dist/components/use-wizard-form-draft.d.ts +51 -0
  39. package/dist/components/use-wizard-form-draft.js +291 -0
  40. package/dist/dev-console.d.ts +3 -0
  41. package/dist/dev-console.js +15 -0
  42. package/dist/headless/contracts/primitives.js +14 -17
  43. package/dist/headless/format/index.js +3 -3
  44. package/dist/headless/view-model/edit.d.ts +1 -1
  45. package/dist/headless/view-model/edit.js +9 -16
  46. package/dist/headless/view-model/list.d.ts +1 -1
  47. package/dist/headless/view-model/list.js +3 -8
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +5 -5
  50. package/dist/live/error-mapping.js +3 -4
  51. package/dist/web/app/create-app.js +5 -8
  52. package/dist/web/app/dashboard-body.js +4 -599
  53. package/dist/web/app/dashboard-panels.d.ts +53 -0
  54. package/dist/web/app/dashboard-panels.js +373 -0
  55. package/dist/web/app/dashboard-shared.d.ts +90 -0
  56. package/dist/web/app/dashboard-shared.js +171 -0
  57. package/dist/web/app/dashboard-text.d.ts +6 -0
  58. package/dist/web/app/dashboard-text.js +65 -0
  59. package/dist/web/layout/nav-tree.js +3 -4
  60. package/dist/web/layout/target-resolver-stub.js +2 -2
  61. package/dist/web/primitives/banner.d.ts +4 -0
  62. package/dist/web/primitives/banner.js +28 -0
  63. package/dist/web/primitives/button.d.ts +7 -0
  64. package/dist/web/primitives/button.js +48 -0
  65. package/dist/web/primitives/card-styles.d.ts +8 -0
  66. package/dist/web/primitives/card-styles.js +18 -0
  67. package/dist/web/primitives/card.d.ts +4 -0
  68. package/dist/web/primitives/card.js +41 -0
  69. package/dist/web/primitives/contexts.d.ts +22 -0
  70. package/dist/web/primitives/contexts.js +50 -0
  71. package/dist/web/primitives/data-table-cell.d.ts +76 -0
  72. package/dist/web/primitives/data-table-cell.js +403 -0
  73. package/dist/web/primitives/data-table.d.ts +4 -0
  74. package/dist/web/primitives/data-table.js +279 -0
  75. package/dist/web/primitives/default-form.d.ts +4 -0
  76. package/dist/web/primitives/default-form.js +79 -0
  77. package/dist/web/primitives/facet-filter.d.ts +14 -0
  78. package/dist/web/primitives/facet-filter.js +39 -0
  79. package/dist/web/primitives/field.d.ts +63 -0
  80. package/dist/web/primitives/field.js +181 -0
  81. package/dist/web/primitives/form.d.ts +53 -0
  82. package/dist/web/primitives/form.js +252 -0
  83. package/dist/web/primitives/index.d.ts +7 -30
  84. package/dist/web/primitives/index.js +27 -2006
  85. package/dist/web/primitives/input.d.ts +4 -0
  86. package/dist/web/primitives/input.js +166 -0
  87. package/dist/web/primitives/layout-elements.d.ts +14 -0
  88. package/dist/web/primitives/layout-elements.js +194 -0
  89. package/dist/web/primitives/modal.js +1 -1
  90. package/dist/web/primitives/pager.d.ts +22 -0
  91. package/dist/web/primitives/pager.js +89 -0
  92. package/dist/web/primitives/row-actions.d.ts +13 -0
  93. package/dist/web/primitives/row-actions.js +178 -0
  94. package/dist/web/primitives/secret-reveal.d.ts +4 -0
  95. package/dist/web/primitives/secret-reveal.js +67 -0
  96. package/package.json +5 -5
  97. package/src/changes.json +6 -0
@@ -1,236 +1,31 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
4
- import { ErrorReference } from "../app/error-reference.js";
5
- import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "../headless/index.js";
6
- import { changedDerivedValues } from "./form-derivation-patch.js";
7
- import { RenderEditActionButton } from "./render-edit-action-button.js";
8
- import { AllFieldsRequiredProvider } from "./render-field.js";
9
- import { useActionMenuItems } from "./use-action-menu-items.js";
2
+ import { PROJECTION_DETAIL_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
10
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { ErrorReference } from "../app/error-reference.js";
11
5
  import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
12
6
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
13
7
  import { layoutEditFields } from "../app/layout-fields.js";
14
8
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
15
9
  import { useDraftStorage } from "../context/draft-storage-context.js";
16
10
  import { formatWhen } from "../format-when.js";
11
+ import { computeEditViewModel, groupIssuesByPath } from "../headless/index.js";
17
12
  import { useForm } from "../hooks/use-form.js";
18
13
  import { useTranslation } from "../i18n.js";
19
14
  import { useInsideDrawer } from "../inside-drawer.js";
20
15
  import { useUnsavedInputSource } from "../leave-guard.js";
21
16
  import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
22
17
  import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
18
+ import { EditSlotMount, ExtensionSectionMount } from "./edit-mounts.js";
19
+ import { changedDerivedValues } from "./form-derivation-patch.js";
23
20
  import { GridCellForField } from "./grid-cell-for-field.js";
24
21
  import { RelatedListSection } from "./related-list-section.js";
22
+ import { RenderEditActionButton } from "./render-edit-action-button.js";
23
+ import { deriveFormFields, dryRunFieldIssues, dryRunParse, formSectionDomId, isFieldsStepFilled, resolveRecordTitle, resolveTitleTemplate, tabIdAt, wizardStepKey, } from "./render-edit-helpers.js";
25
24
  import { filterEditSections, findFirstErroringSectionIndex, hasEditableSection, hasIssueWithoutRenderedField, isDispatcherRejection, issuesAfterEdit, ROOT_ISSUE_PATH, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
25
+ import { AllFieldsRequiredProvider } from "./render-field.js";
26
+ import { useActionMenuItems } from "./use-action-menu-items.js";
27
+ import { buildWizardDraftActions, PATCH_DRAFT_SAVE_DEBOUNCE_MS, useWizardFormDraftRestore, useWizardFormDraftState, } from "./use-wizard-form-draft.js";
26
28
  import { WriteFormSection } from "./write-form-section.js";
27
- // Qualified names of the bundled `form-draft` feature. Hardcoded because the
28
- // renderer must not depend on @cosmicdrift/kumiko-bundled-features; a screen
29
- // with `layout.draft: true` and that feature unmounted is a boot error, so
30
- // these can never dangle silently.
31
- const FORM_DRAFT_GET = "form-draft:query:get";
32
- const FORM_DRAFT_SAVE = "form-draft:write:save";
33
- const FORM_DRAFT_DISCARD = "form-draft:write:discard";
34
- const FORM_DRAFT_LIST = "form-draft:query:list";
35
- // Mirrors bundled-features/src/form-draft/web/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
36
- // — same hardcoding rationale as the QNs above (no dependency on that package).
37
- const FORM_DRAFT_KEY_MAX_LENGTH = 256;
38
- // Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
39
- // patch() call (VIN-decode, an extension section) should not save immediately
40
- // per call, but patch() can also fire from inside onChange on every keystroke
41
- // (the #1888 derived-field shape) — 500ms collapses a typing burst into one
42
- // save instead of one per keystroke, while still saving well before a user
43
- // abandons the tab.
44
- export const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
45
- // draftKey convention for a create-mode draft (framework-wizard-mode.md,
46
- // lookup.ts): `${screenId}:new:${draftId}`. Shared by the mount-time list
47
- // fallback (strip the prefix to recover a candidate's draftId) and the
48
- // list query itself (filter out edit-mode drafts, which share the same
49
- // `${screenId}:%` LIKE-prefix scan server-side).
50
- function newDraftPrefix(screenId) {
51
- return `${screenId}:new:`;
52
- }
53
- // `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
54
- // without a polyfill — guard like generateRequestId in headless/dispatcher/request-id.ts.
55
- function mintDraftId() {
56
- const webCrypto = propertyOf(globalThis, "crypto");
57
- const randomUUID = propertyOf(webCrypto, "randomUUID");
58
- if (typeof randomUUID === "function") {
59
- const id = Reflect.apply(randomUUID, webCrypto, []);
60
- if (typeof id === "string")
61
- return id;
62
- }
63
- return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
64
- }
65
- // End-to-end renderer für einen entityEdit screen. Rendert aus-
66
- // schließlich über Primitives — kein raw HTML. Ein Native-Renderer
67
- // der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
68
- // Änderungen.
69
- function toConditionValue(cond) {
70
- if (typeof cond === "boolean")
71
- return cond;
72
- // @cast-boundary form-values: TValues ist strukturell ein Record.
73
- return (values) => evalFieldCondition(cond, values);
74
- }
75
- // Tab-strip key. The boot-validator makes `id` mandatory on a tabs layout and
76
- // entityEdit only ever has "fields"/"extension" sections (relatedList and
77
- // writeForm are rejected outright), so the positional fallback is unreachable
78
- // for a tabs screen — it only keeps this total for other layouts.
79
- function tabIdAt(section, index) {
80
- const declared = section?.kind === "fields" || section?.kind === "extension" ? section.id : undefined;
81
- return declared ?? `section-${index}`;
82
- }
83
- function deriveFormFields(screen) {
84
- const out = {};
85
- for (const section of screen.layout.sections) {
86
- // relatedList carries no `fields` for the form-condition map either.
87
- if (!isFieldsEditSection(section))
88
- continue;
89
- for (const spec of sectionFieldSpecs(section)) {
90
- const normalized = normalizeEditField(spec);
91
- out[normalized.field] = {
92
- ...(normalized.visible !== undefined && {
93
- visible: toConditionValue(normalized.visible),
94
- }),
95
- ...(normalized.readOnly !== undefined && {
96
- readonly: toConditionValue(normalized.readOnly),
97
- }),
98
- ...(normalized.required !== undefined && {
99
- required: toConditionValue(normalized.required),
100
- }),
101
- };
102
- }
103
- }
104
- return out;
105
- }
106
- // Dry-run parse against `schema` — NOT controller.validate(), which writes
107
- // field errors into the snapshot. Undefined when the values parse cleanly.
108
- // Parse once, then narrow per scope with scopedDryRunIssues: the wizard step
109
- // check would otherwise re-parse the whole form per section on every keystroke.
110
- function dryRunParse(schema, snapshot) {
111
- const parsed = schema.safeParse(snapshot.values);
112
- if (parsed.success)
113
- return undefined;
114
- const hiddenFields = new Set(Object.entries(snapshot.fields)
115
- .filter(([, state]) => !state.visible)
116
- .map(([fieldKey]) => fieldKey));
117
- return { issues: zodErrorToFieldIssues(parsed.error), hiddenFields };
118
- }
119
- // Issues are filtered exactly like the controller's validate(): hidden
120
- // fields never count, `scope` narrows.
121
- function scopedDryRunIssues(parse, scope, includeRoot) {
122
- if (parse === undefined)
123
- return [];
124
- return relevantFieldIssues(parse.issues, {
125
- hiddenFields: parse.hiddenFields,
126
- ...(scope !== undefined && { scope }),
127
- includeRoot,
128
- });
129
- }
130
- function dryRunFieldIssues(schema, snapshot, scope, includeRoot) {
131
- return scopedDryRunIssues(dryRunParse(schema, snapshot), scope, includeRoot);
132
- }
133
- // Index-independent: indices shift when a fields section hides.
134
- function wizardStepKey(section, index) {
135
- const declaredId = section.kind === "fields" || section.kind === "extension" ? section.id : undefined;
136
- return declaredId ?? section.title ?? `step-${index}`;
137
- }
138
- function isMoneyValue(value) {
139
- return typeof value === "object" && value !== null && "amount" in value;
140
- }
141
- // The seeds buildInitialValues writes for NULL columns of an existing record.
142
- function isEmptyFormValue(value) {
143
- if (value === null || value === undefined || value === "" || value === 0 || value === false) {
144
- return true;
145
- }
146
- if (Array.isArray(value))
147
- return value.length === 0;
148
- return isMoneyValue(value) && value.amount === 0;
149
- }
150
- // select/boolean are excluded from the "holds user data" check: they are
151
- // often pre-filled (defaults, server-set values), so they say nothing about
152
- // whether the step was filled in. They still count through the validation.
153
- function isFieldsStepFilled(section, parse) {
154
- const fieldNames = section.fields.map((f) => f.field);
155
- if (scopedDryRunIssues(parse, fieldNames, false).length > 0)
156
- return false;
157
- return section.fields.some((f) => f.visible &&
158
- !f.readOnly &&
159
- f.type !== "boolean" &&
160
- f.type !== "select" &&
161
- !isEmptyFormValue(f.value));
162
- }
163
- // Resolves an extension-section's `{ react: { __component: "X" } }` marker
164
- // to a registered React component via ExtensionSectionsProvider (filled in
165
- // createKumikoApp from clientFeatures.extensionSectionComponents) and
166
- // mounts it with the host entity name + id. Hook lives in its own
167
- // component so we don't call `use*` inside vm.sections.map (rules-of-
168
- // hooks would punish reordering sections between renders).
169
- function ExtensionSectionMount({ section, entityName: hostEntityName, entityId, initialValues, values, patch, validate, markSaved, hideTitle, actions, stepKey, onReportStepComplete, }) {
170
- const { Banner, FillContainer, Section, Text } = usePrimitives();
171
- const name = extensionSectionName(section.component);
172
- const Component = useExtensionSectionComponent(name);
173
- const reportStepComplete = useCallback((complete) => {
174
- if (stepKey !== undefined)
175
- onReportStepComplete?.(stepKey, complete);
176
- }, [stepKey, onReportStepComplete]);
177
- const testId = `section-extension-${section.title}`;
178
- if (Component === undefined) {
179
- const placeholder = (_jsx(Banner, { variant: "info", testId: `section-extension-placeholder-${section.title}`, children: _jsxs(Text, { children: ["Extension section component ", _jsx(Text, { variant: "code", children: name ?? "(no __component name)" }), " ", "not registered in clientFeatures.extensionSectionComponents."] }) }));
180
- // Tabs mode already frames this in the caller's own Card — a Section
181
- // here would add a second, unwanted divider inset (px-6 py-4) on top of
182
- // the Card's own padding, misaligning this tab's content against the
183
- // fields tab (Grid sits directly in the Card there). FillContainer keeps
184
- // the testId without adding chrome (same primitive RelatedListSection
185
- // uses for its own hideTitle branch).
186
- return hideTitle === true ? (FillContainer !== undefined ? (_jsx(FillContainer, { testId: testId, children: placeholder })) : (placeholder)) : (_jsx(Section, { title: section.title, testId: testId, children: placeholder }, section.title));
187
- }
188
- const mounted = (_jsx(Component, { entityName: section.entityName ?? hostEntityName, entityId: entityId, initialValues: initialValues, values: values, patch: patch, validate: validate, markSaved: markSaved, ...(onReportStepComplete !== undefined && { reportStepComplete }) }));
189
- // Same reasoning as the placeholder branch above: tabs mode's outer Card
190
- // is the only frame this content gets, no nested Section divider.
191
- return hideTitle === true ? (FillContainer !== undefined ? (_jsx(FillContainer, { testId: testId, children: mounted })) : (mounted)) : (_jsx(Section, { ...(actions !== undefined && { actions }), title: section.title, testId: testId, children: mounted }));
192
- }
193
- // Resolves an entityEdit slot's (`screen.slots.header` / `.footer`)
194
- // `{ react: { __component: "X" } }` marker via the same ExtensionSectionsProvider
195
- // registry as ExtensionSectionMount / ListHeaderSlotMount and mounts it. One
196
- // component for both slots — header passes no hasUnsavedChanges/wizardStep,
197
- // footer does. Own component for rules-of-hooks (useExtensionSectionComponent
198
- // must not run conditionally inside RenderEdit's render body).
199
- function EditSlotMount({ slot, slotName, screenId, entityName, entityId, values, markSaved, hasUnsavedChanges, wizardStep, }) {
200
- const name = extensionSectionName(slot);
201
- const Component = useExtensionSectionComponent(name);
202
- useEffect(() => {
203
- if (name !== undefined && Component === undefined) {
204
- // biome-ignore lint/suspicious/noConsole: dev warning for a setup error
205
- console.warn(`[kumiko] Edit ${slotName} slot component "${name}" on screen "${screenId}" is not registered in clientFeatures.extensionSectionComponents — the ${slotName} slot renders nothing.`);
206
- }
207
- }, [name, Component, screenId, slotName]);
208
- if (Component === undefined)
209
- return null;
210
- return (_jsx(Component, { entityName: entityName, entityId: entityId, values: values, ...(markSaved !== undefined && { markSaved }), ...(hasUnsavedChanges !== undefined && { hasUnsavedChanges }), ...(wizardStep !== undefined && { wizardStep }) }));
211
- }
212
- function formSectionDomId(sectionIndex) {
213
- return `form-section-${sectionIndex}`;
214
- }
215
- /** A record title is a non-blank string; anything else leaves the screen title alone. */
216
- export function resolveRecordTitle(value) {
217
- return typeof value === "string" && value.trim() !== "" ? value : undefined;
218
- }
219
- const TITLE_PLACEHOLDER_PATTERN = /\{(\w+)\}/g;
220
- // Falls back to the plain title while any placeholder value is still empty,
221
- // so the header never shows "Add " mid-typing.
222
- function resolveTitleTemplate(template, values, translate, fallback) {
223
- if (template === undefined)
224
- return fallback;
225
- const placeholders = [...translate(template).matchAll(TITLE_PLACEHOLDER_PATTERN)].map((match) => match[1] ?? "");
226
- const isFilled = (name) => {
227
- const value = values[name];
228
- return value !== undefined && value !== null && String(value).trim() !== "";
229
- };
230
- if (!placeholders.every(isFilled))
231
- return fallback;
232
- return translate(template, values);
233
- }
234
29
  export function RenderEdit(props) {
235
30
  const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, formDerivation, actionFormScreenQn, } = props;
236
31
  // View mode is a locked form, so everything keyed on `disabled` follows.
@@ -280,24 +75,13 @@ export function RenderEdit(props) {
280
75
  // Tabs have stable ids, so the active tab follows its id: a field change
281
76
  // that hides an earlier tab must not shift the index onto another tab.
282
77
  const [activeTabId, setActiveTabId] = useState(undefined);
283
- // Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
284
- // on mount so a same-tab reload finds the right one of several parallel
285
- // create-sessions on this screen; `null` until the first step change
286
- // mints one (saveDraft), or the mount-time `form-draft:query:list`
287
- // fallback below adopts an existing one. Edit-mode never touches this —
288
- // its draftKey is `${screen.id}:${entityIdProp}` unconditionally.
289
- const [draftId, setDraftId] = useState(() => isCreateMode ? draftStorage.getDraftId(screen.id) : null);
290
- // Marks a draftId this instance minted itself (saveDraft, first step
291
- // change) so the restore effect below doesn't immediately re-fetch what
292
- // it just wrote — there is nothing to restore, the row is brand new.
293
- const mintedDraftIdRef = useRef(null);
294
- // Marks that the mount-time `form-draft:query:list` fallback already ran
295
- // once for this mount. Without this, a create-mode discard resets `draftId`
296
- // to `null` (see discardDraft below) and would re-arm the list effect —
297
- // silently adopting an unrelated parallel draft on the same screen right
298
- // after the user just submitted (#1908).
299
- const didListRef = useRef(false);
300
- const [draftCandidates, setDraftCandidates] = useState(null);
78
+ const draftState = useWizardFormDraftState({
79
+ screenId: screen.id,
80
+ entityId: entityIdProp,
81
+ isCreateMode,
82
+ draftStorage,
83
+ });
84
+ const { draftCandidates, setDraftCandidates } = draftState;
301
85
  // Composed-Save: Extension-Sections melden hier ihren dirty-State (damit der
302
86
  // Save-Button aktiv wird wenn NUR eine Section geändert wurde) + ihren
303
87
  // Submit-Handler (läuft nach dem Entity-Write). extensionErrorKey hält den
@@ -407,18 +191,6 @@ export function RenderEdit(props) {
407
191
  controller.setValues(initial);
408
192
  controller.rebase();
409
193
  }, [refreshToken, controller, initial]);
410
- // Derived from the screen id + the host entity id only — never from
411
- // `vm.id`, which lives in the form values a restore mutates (load under one
412
- // key, save under another). Edit-mode: `${screen.id}:${entityIdProp}`,
413
- // unchanged. Create-mode: `${screen.id}:new:${draftId}` once a draftId
414
- // exists (resumed, adopted, or minted), `undefined` before that — two
415
- // parallel create sessions on the same screen must never collapse onto
416
- // the same key (kumiko-framework#1908).
417
- const draftKey = useMemo(() => isCreateMode
418
- ? draftId !== null
419
- ? `${screen.id}:new:${draftId}`
420
- : undefined
421
- : `${screen.id}:${entityIdProp}`, [screen.id, entityIdProp, isCreateMode, draftId]);
422
194
  // Controlled mode (Issue #1887). Both callbacks live in refs so a
423
195
  // re-rendering caller (identity-unstable `onChange`/`onControlsReady`
424
196
  // closures, e.g. inline arrows) doesn't retrigger these effects — only a
@@ -455,8 +227,7 @@ export function RenderEdit(props) {
455
227
  // wizard step without depending on `currentStep` (computed further down,
456
228
  // from `filteredSections`) at the point patchAndScheduleDraftSave itself
457
229
  // is defined — kept fresh where `currentStep` is computed below.
458
- const saveDraftRef = useRef(saveDraft);
459
- saveDraftRef.current = saveDraft;
230
+ const saveDraftRef = useRef(() => { });
460
231
  // `handleSubmit` (defined below) is a fresh closure over this render's
461
232
  // snapshot/extensionDirty, while the onControlsReady effect fires once per
462
233
  // mount — the ref lets controls.submit() always reach the current one.
@@ -566,111 +337,16 @@ export function RenderEdit(props) {
566
337
  // Draft wizards keep their input server-side, so leaving loses nothing.
567
338
  const persistsDraft = isWizard && screen.layout.draft === true;
568
339
  useUnsavedInputSource(() => !persistsDraft && (controller.getSnapshot().isDirty || extensionDirtyRef.current));
569
- useEffect(() => {
570
- // skip: this screen does not persist a draft.
571
- if (!draftEnabled || dispatcher === undefined)
572
- return;
573
- // skip: create-mode with no draftId yet — nothing to restore, either
574
- // the list-fallback effect below finds one or the first step change
575
- // mints a fresh one.
576
- if (draftKey === undefined)
577
- return;
578
- // skip: this draftId was just minted by saveDraft — the row it wrote
579
- // is exactly the current form state, re-fetching it would be a no-op
580
- // round-trip at best and a stale-echo clobber at worst.
581
- if (draftId !== null && draftId === mintedDraftIdRef.current)
582
- return;
583
- let cancelled = false;
584
- void (async () => {
585
- try {
586
- const result = await dispatcher.query(FORM_DRAFT_GET, { draftKey });
587
- // skip: superseded by a newer mount, or the draft lookup failed — an
588
- // unreachable draft store must not break the form itself.
589
- if (cancelled || !result.isSuccess)
590
- return;
591
- const draft = result.data?.draft ?? null;
592
- // skip: nothing saved yet for this key.
593
- if (draft === null)
594
- return;
595
- // skip: the user already typed or wizard-navigated while the lookup
596
- // was in flight — their input wins over the stored draft.
597
- if (controller.getSnapshot().isDirty || hasNavigatedRef.current)
598
- return;
599
- // @cast-boundary form-values draft blob: `values` round-trips through an opaque
600
- // jsonb column and comes back untyped.
601
- controller.setValues(draft.values);
602
- setRawStep(Math.max(draft.stepIndex, 0));
603
- }
604
- catch (err) {
605
- // biome-ignore lint/suspicious/noConsole: draft restore must not red-screen the form
606
- console.warn("render-edit: draft restore query failed", err);
607
- }
608
- })();
609
- return () => {
610
- cancelled = true;
611
- };
612
- }, [draftEnabled, draftKey, draftId, dispatcher, controller]);
613
- // Mount-time fallback (issue #1913) for create-mode when no draftId
614
- // survived in storage (new tab, cleared storage): ask the server for this
615
- // screen's open drafts and let the user choose via a picker (below) —
616
- // even for exactly one candidate. Auto-adopting a lone candidate would
617
- // silently hand this tab someone else's in-progress draft from a parallel
618
- // session on the same screen (cross-tab hijack); the picker's "start new"
619
- // action covers the common case (it really was this tab's own draft) with
620
- // one extra click. Zero candidates → stay null, saveDraft mints a fresh
621
- // draftId on the first step change same as any other fresh create.
622
- useEffect(() => {
623
- // skip: this screen does not persist a draft, this is edit-mode, a
624
- // draftId is already known (from storage or an earlier adoption), or
625
- // this mount already ran the list lookup once (didListRef).
626
- if (!draftEnabled ||
627
- !isCreateMode ||
628
- draftId !== null ||
629
- didListRef.current ||
630
- dispatcher === undefined)
631
- return;
632
- let cancelled = false;
633
- void (async () => {
634
- try {
635
- const result = await dispatcher.query(FORM_DRAFT_LIST, { screenId: screen.id });
636
- // skip: superseded by a newer mount, or the lookup failed — an
637
- // unreachable draft store must not break a fresh create.
638
- if (cancelled || !result.isSuccess)
639
- return;
640
- didListRef.current = true;
641
- const prefix = newDraftPrefix(screen.id);
642
- // list's LIKE-prefix scan also matches edit-mode drafts
643
- // (`${screenId}:${entityId}`) — keep only create-mode ones.
644
- const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
645
- // skip: no open drafts for this screen — stay null, saveDraft mints a
646
- // fresh draftId on the first step change same as any other create.
647
- if (candidates.length === 0)
648
- return;
649
- setDraftCandidates(candidates);
650
- }
651
- catch (err) {
652
- // biome-ignore lint/suspicious/noConsole: draft lookup must not red-screen the form
653
- console.warn("render-edit: draft list query failed", err);
654
- }
655
- })();
656
- return () => {
657
- cancelled = true;
658
- };
659
- }, [draftEnabled, isCreateMode, draftId, dispatcher, screen.id]);
660
- // User picked one of several open drafts from the mount-time picker
661
- // (see draftCandidates below) — adopt it the same way a single
662
- // auto-adopted candidate would be.
663
- function adoptDraft(candidate) {
664
- // Locked state (#1896/fw#1909): `disabled` means "no write possible" —
665
- // repointing draftKey and patching in the candidate's values is exactly
666
- // that, so a locked form must not adopt a draft.
667
- if (disabled)
668
- return;
669
- const adoptedId = candidate.draftKey.slice(newDraftPrefix(screen.id).length);
670
- draftStorage.setDraftId(screen.id, adoptedId);
671
- setDraftId(adoptedId);
672
- setDraftCandidates(null);
673
- }
340
+ useWizardFormDraftRestore({
341
+ screenId: screen.id,
342
+ isCreateMode,
343
+ draftEnabled,
344
+ dispatcher,
345
+ controller,
346
+ hasNavigatedRef,
347
+ setRawStep,
348
+ draftState,
349
+ });
674
350
  const vm = useMemo(() => computeEditViewModel({
675
351
  screen,
676
352
  entity,
@@ -799,63 +475,19 @@ export function RenderEdit(props) {
799
475
  if (isTabs)
800
476
  setActiveTabId(tabIdAt(filteredSections[index], index));
801
477
  }
802
- // Step transitions only — never per keystroke. Deliberately not awaited: a
803
- // failed draft save must not block the step change.
804
- //
805
- // Create-mode, first draft save (step change OR a debounced patch() from
806
- // controlled mode / extension sections, see patchAndScheduleDraftSave
807
- // above — both call this on step 0 too): mints the draftId here (issue
808
- // #1913) rather than at mount, so a form nobody ever interacted with
809
- // never claims a draftId or writes a row. `draftKey`/`draftId` state
810
- // won't reflect the mint until the next render, so the just-minted key is
811
- // computed inline instead of read from the memoized `draftKey`.
812
- function saveDraft(stepIndex) {
813
- // skip: this screen does not persist a draft.
814
- if (!draftEnabled || dispatcher === undefined)
815
- return;
816
- let key = draftKey;
817
- if (isCreateMode && draftId === null) {
818
- const mintedId = mintDraftId();
819
- mintedDraftIdRef.current = mintedId;
820
- draftStorage.setDraftId(screen.id, mintedId);
821
- setDraftId(mintedId);
822
- key = `${screen.id}:new:${mintedId}`;
823
- // A stale picker from the mount-time list fallback must not survive a
824
- // mint — picking a candidate afterwards would repoint draftKey at an
825
- // unrelated draft mid-edit and overwrite it (#1908).
826
- setDraftCandidates(null);
827
- }
828
- // skip: create-mode with a draftId that failed to mint — unreachable
829
- // in practice (mint above always produces one), kept as a type-level
830
- // guard against a stale `undefined` key ever reaching the write.
831
- if (key === undefined)
832
- return;
833
- // draftKeySchema.max(FORM_DRAFT_KEY_MAX_LENGTH) rejects this server-side —
834
- // catch it here instead of losing the save silently to the fire-and-forget
835
- // write below (the user would only notice on resume, with nothing to resume).
836
- if (key.length > FORM_DRAFT_KEY_MAX_LENGTH) {
837
- // biome-ignore lint/suspicious/noConsole: no error-surfacing path exists for a fire-and-forget draft save.
838
- console.warn(`RenderEdit: draftKey "${key}" is ${key.length} chars, over the server's ${FORM_DRAFT_KEY_MAX_LENGTH}-char limit — skipping this draft save. Shorten screen.id or entity id.`);
839
- return;
840
- }
841
- const secretFieldNames = new Set(vm.sections.flatMap((section) => section.kind === "fields"
842
- ? section.fields
843
- .filter((f) => f.format === "password" || f.writeOnly === true || f.sensitive === true)
844
- .map((f) => f.field)
845
- : []));
846
- // A password, writeOnly or sensitive field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
847
- const draftValues = Object.fromEntries(Object.entries(controller.getSnapshot().values).filter(([field]) => !secretFieldNames.has(field)));
848
- void dispatcher
849
- .write(FORM_DRAFT_SAVE, {
850
- draftKey: key,
851
- values: draftValues,
852
- stepIndex,
853
- })
854
- .catch((err) => {
855
- // biome-ignore lint/suspicious/noConsole: fire-and-forget draft save must not red-screen
856
- console.warn("render-edit: draft save failed", err);
857
- });
858
- }
478
+ const { adoptDraft, saveDraft, discardDraft } = buildWizardDraftActions({
479
+ screenId: screen.id,
480
+ isCreateMode,
481
+ draftEnabled,
482
+ disabled,
483
+ dispatcher,
484
+ draftStorage,
485
+ controller,
486
+ sections: vm.sections,
487
+ draftSaveTimerRef,
488
+ draftState,
489
+ });
490
+ saveDraftRef.current = saveDraft;
859
491
  // Next: scoped validate() on the current step's fields — errors stay
860
492
  // attached to the field and block the step transition. Extension steps
861
493
  // have no field scope here (they validate via the controlled-mode
@@ -912,40 +544,6 @@ export function RenderEdit(props) {
912
544
  if (!disabled)
913
545
  saveDraft(clampedTarget);
914
546
  }
915
- async function discardDraft() {
916
- // skip: this screen does not persist a draft.
917
- if (!draftEnabled || dispatcher === undefined)
918
- return;
919
- // A pending debounced patch-save must not fire after discard — it would
920
- // resurrect the draft it just deleted.
921
- if (draftSaveTimerRef.current !== null) {
922
- clearTimeout(draftSaveTimerRef.current);
923
- draftSaveTimerRef.current = null;
924
- }
925
- // skip: create-mode, no step change happened yet — no draftId was ever
926
- // minted, so no row exists to discard.
927
- if (draftKey === undefined)
928
- return;
929
- // Best-effort: a rejected write (e.g. network error) must not propagate
930
- // out of handleSubmit AFTER the entity write already succeeded — that
931
- // would break onSubmit/navigation/toast and risk a duplicate submit on
932
- // retry. An orphaned draft row is swept later by cleanup.job.ts. Local
933
- // cleanup below still runs on failure so this instance doesn't try to
934
- // resume a draft it already considers submitted.
935
- try {
936
- await dispatcher.write(FORM_DRAFT_DISCARD, { draftKey });
937
- }
938
- catch {
939
- // best-effort, see comment above.
940
- }
941
- // A successful submit ends this draftId's life — a subsequent create on
942
- // the same screen (new mount) must mint its own, not resume this one.
943
- if (isCreateMode) {
944
- draftStorage.clearDraftId(screen.id);
945
- mintedDraftIdRef.current = null;
946
- setDraftId(null);
947
- }
948
- }
949
547
  // Stepped modes (wizard, tabs): locates the first step whose "fields"
950
548
  // section contains one of the given field paths — matched on the path's
951
549
  // first segment, since `FieldIssue.path`/`snapshot.errors` keys can be
@@ -11,6 +11,7 @@ import { evalRowExtractor } from "../app/row-actions.js";
11
11
  import { screenAccessAllows } from "../app/screen-access.js";
12
12
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
13
13
  import { useUserRoles } from "../context/user-roles-context.js";
14
+ import { rendererError, rendererWarn } from "../dev-console.js";
14
15
  import { fileIdsOf } from "../file-ids-of.js";
15
16
  import { applyFormatSpec, currencyDecimals, EMPTY_VALUE_PLACEHOLDER, } from "../headless/index.js";
16
17
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
@@ -228,8 +229,7 @@ function ReferenceInput({ field, id, hasError, onChange, Input, featureName, })
228
229
  // be missing from `options` (shown as a raw id instead of a label).
229
230
  setSearchTerm("");
230
231
  queryResult.refetch().catch((err) => {
231
- // biome-ignore lint/suspicious/noConsole: no error-surfacing path exists in this widget yet — at minimum, don't swallow it silently.
232
- console.error("render-field: refetch after create failed", err);
232
+ rendererError("render-field: refetch after create failed", err);
233
233
  });
234
234
  if (newId === undefined) {
235
235
  // Record was created server-side but the payload carried no id —
@@ -319,8 +319,7 @@ function FieldRendererOutput({ field, renderer, row, appLocale, }) {
319
319
  if (Component !== undefined) {
320
320
  return (_jsx(Component, { value: field.value, row: row ?? { [field.field]: field.value }, column: { field: field.field } }));
321
321
  }
322
- // biome-ignore lint/suspicious/noConsole: dev-warning for a registry mismatch, mirrors DataTableCell's columnRenderer warning.
323
- console.warn(`[kumiko] fieldRenderer "${componentName}" not registered`);
322
+ rendererWarn(`[kumiko] fieldRenderer "${componentName}" not registered`);
324
323
  }
325
324
  return _jsx(Text, { testId: `field-value-${field.field}`, children: stringValue(field.value) });
326
325
  }
@@ -3,6 +3,7 @@ import { UNRESOLVED_REFERENCE_LABEL_KEYS, } from "@cosmicdrift/kumiko-framework/
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
5
5
  import { useReferenceLinkResolver } from "../app/reference-link.js";
6
+ import { rendererWarn } from "../dev-console.js";
6
7
  import { computeListViewModel, EMPTY_VALUE_PLACEHOLDER } from "../headless/index.js";
7
8
  import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
8
9
  import { useTranslation } from "../i18n.js";
@@ -251,8 +252,7 @@ function ListHeaderSlotMount({ screen, }) {
251
252
  // (the toolbar has no room for a banner, so a dev-warn it is).
252
253
  useEffect(() => {
253
254
  if (name !== undefined && Component === undefined) {
254
- // biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
255
- console.warn(`[kumiko] List header slot component "${name}" on screen "${screen.id}" is not registered in clientFeatures.extensionSectionComponents — the header slot renders nothing.`);
255
+ rendererWarn(`[kumiko] List header slot component "${name}" on screen "${screen.id}" is not registered in clientFeatures.extensionSectionComponents — the header slot renders nothing.`);
256
256
  }
257
257
  }, [name, Component, screen.id]);
258
258
  if (Component === undefined)
@@ -0,0 +1,51 @@
1
+ import { type MutableRefObject } from "react";
2
+ import type { DraftStorage } from "../context/draft-storage-context.tsx";
3
+ import type { Dispatcher, EditSectionViewModel, FormController, FormValues } from "../headless/index.ts";
4
+ export declare const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
5
+ export type DraftCandidate = {
6
+ readonly id: string;
7
+ readonly draftKey: string;
8
+ readonly stepIndex: number;
9
+ readonly savedAt: string;
10
+ };
11
+ export declare function useWizardFormDraftState({ screenId, entityId, isCreateMode, draftStorage, }: {
12
+ readonly screenId: string;
13
+ readonly entityId: string | null | undefined;
14
+ readonly isCreateMode: boolean;
15
+ readonly draftStorage: DraftStorage;
16
+ }): {
17
+ draftId: string | null;
18
+ setDraftId: import("react").Dispatch<import("react").SetStateAction<string | null>>;
19
+ draftCandidates: readonly DraftCandidate[] | null;
20
+ setDraftCandidates: import("react").Dispatch<import("react").SetStateAction<readonly DraftCandidate[] | null>>;
21
+ mintedDraftIdRef: import("react").RefObject<string | null>;
22
+ didListRef: import("react").RefObject<boolean>;
23
+ draftKey: string | undefined;
24
+ };
25
+ export type WizardFormDraftState = ReturnType<typeof useWizardFormDraftState>;
26
+ export declare function useWizardFormDraftRestore<TValues extends FormValues>({ screenId, isCreateMode, draftEnabled, dispatcher, controller, hasNavigatedRef, setRawStep, draftState: { draftId, draftKey, mintedDraftIdRef, didListRef, setDraftCandidates }, }: {
27
+ readonly screenId: string;
28
+ readonly isCreateMode: boolean;
29
+ readonly draftEnabled: boolean;
30
+ readonly dispatcher: Dispatcher | undefined;
31
+ readonly controller: FormController<TValues>;
32
+ readonly hasNavigatedRef: MutableRefObject<boolean>;
33
+ readonly setRawStep: (step: number) => void;
34
+ readonly draftState: WizardFormDraftState;
35
+ }): void;
36
+ export declare function buildWizardDraftActions<TValues extends FormValues>({ screenId, isCreateMode, draftEnabled, disabled, dispatcher, draftStorage, controller, sections, draftSaveTimerRef, draftState: { draftId, draftKey, mintedDraftIdRef, setDraftId, setDraftCandidates }, }: {
37
+ readonly screenId: string;
38
+ readonly isCreateMode: boolean;
39
+ readonly draftEnabled: boolean;
40
+ readonly disabled: boolean;
41
+ readonly dispatcher: Dispatcher | undefined;
42
+ readonly draftStorage: DraftStorage;
43
+ readonly controller: FormController<TValues>;
44
+ readonly sections: readonly EditSectionViewModel[];
45
+ readonly draftSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>;
46
+ readonly draftState: WizardFormDraftState;
47
+ }): {
48
+ adoptDraft: (candidate: DraftCandidate) => void;
49
+ saveDraft: (stepIndex: number) => void;
50
+ discardDraft: () => Promise<void>;
51
+ };