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