@cosmicdrift/kumiko-renderer 0.389.0 → 0.390.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/projection-detail-body.js +59 -394
- package/dist/app/projection-detail-header-model.d.ts +11 -0
- package/dist/app/projection-detail-header-model.js +33 -0
- package/dist/app/projection-detail-header-region.d.ts +28 -0
- package/dist/app/projection-detail-header-region.js +81 -0
- package/dist/app/projection-detail-header.d.ts +29 -0
- package/dist/app/projection-detail-header.js +84 -0
- package/dist/app/projection-detail-navigation.d.ts +13 -0
- package/dist/app/projection-detail-navigation.js +91 -0
- package/dist/app/projection-detail-states.d.ts +22 -0
- package/dist/app/projection-detail-states.js +26 -0
- package/dist/app/projection-drawer-host.d.ts +12 -0
- package/dist/app/projection-drawer-host.js +10 -0
- package/dist/app/projection-list-body.d.ts +1 -1
- package/dist/app/projection-list-body.js +69 -189
- package/dist/app/projection-list-render-props.d.ts +56 -0
- package/dist/app/projection-list-render-props.js +47 -0
- package/dist/app/projection-list-render.d.ts +26 -0
- package/dist/app/projection-list-render.js +20 -0
- package/dist/app/projection-list-row-click.d.ts +12 -0
- package/dist/app/projection-list-row-click.js +14 -0
- package/dist/app/use-projection-detail-actions.d.ts +35 -0
- package/dist/app/use-projection-detail-actions.js +156 -0
- package/dist/app/use-projection-detail-data.d.ts +19 -0
- package/dist/app/use-projection-detail-data.js +47 -0
- package/dist/app/use-projection-detail-layout.d.ts +17 -0
- package/dist/app/use-projection-detail-layout.js +23 -0
- package/dist/app/use-projection-list-actions.d.ts +31 -0
- package/dist/app/use-projection-list-actions.js +73 -0
- package/dist/app/use-projection-list-data.d.ts +20 -0
- package/dist/app/use-projection-list-data.js +26 -0
- package/dist/app/use-projection-list-definitions.d.ts +11 -0
- package/dist/app/use-projection-list-definitions.js +20 -0
- package/dist/app/use-projection-list-query.d.ts +28 -0
- package/dist/app/use-projection-list-query.js +83 -0
- package/dist/components/render-edit-banners.d.ts +12 -0
- package/dist/components/render-edit-banners.js +90 -0
- package/dist/components/render-edit-draft-save-helpers.d.ts +19 -0
- package/dist/components/render-edit-draft-save-helpers.js +42 -0
- package/dist/components/render-edit-field-grid.d.ts +4 -0
- package/dist/components/render-edit-field-grid.js +40 -0
- package/dist/components/render-edit-fields-section.d.ts +4 -0
- package/dist/components/render-edit-fields-section.js +76 -0
- package/dist/components/render-edit-form-actions.d.ts +4 -0
- package/dist/components/render-edit-form-actions.js +66 -0
- package/dist/components/render-edit-header-actions.d.ts +11 -0
- package/dist/components/render-edit-header-actions.js +120 -0
- package/dist/components/render-edit-layout-flags.d.ts +29 -0
- package/dist/components/render-edit-layout-flags.js +106 -0
- package/dist/components/render-edit-layout.d.ts +4 -0
- package/dist/components/render-edit-layout.js +79 -0
- package/dist/components/render-edit-render-model.d.ts +63 -0
- package/dist/components/render-edit-render-model.js +14 -0
- package/dist/components/render-edit-section-kinds.d.ts +4 -0
- package/dist/components/render-edit-section-kinds.js +115 -0
- package/dist/components/render-edit-submit-result.d.ts +8 -0
- package/dist/components/render-edit-submit-result.js +77 -0
- package/dist/components/render-edit-submit.d.ts +7 -0
- package/dist/components/render-edit-submit.js +113 -0
- package/dist/components/render-edit-titles.d.ts +17 -0
- package/dist/components/render-edit-titles.js +99 -0
- package/dist/components/render-edit-wizard-navigation.d.ts +11 -0
- package/dist/components/render-edit-wizard-navigation.js +115 -0
- package/dist/components/render-edit.d.ts +2 -2
- package/dist/components/render-edit.js +63 -1128
- package/dist/components/render-field-choice-inputs.d.ts +7 -0
- package/dist/components/render-field-choice-inputs.js +52 -0
- package/dist/components/render-field-control.d.ts +12 -0
- package/dist/components/render-field-control.js +93 -0
- package/dist/components/render-field-display-text.d.ts +4 -0
- package/dist/components/render-field-display-text.js +48 -0
- package/dist/components/render-field-fill-action.d.ts +13 -0
- package/dist/components/render-field-fill-action.js +63 -0
- package/dist/components/render-field-input-shared.d.ts +39 -0
- package/dist/components/render-field-input-shared.js +20 -0
- package/dist/components/render-field-input.d.ts +3 -0
- package/dist/components/render-field-input.js +34 -0
- package/dist/components/render-field-meta.d.ts +6 -0
- package/dist/components/render-field-meta.js +51 -0
- package/dist/components/render-field-read-only.d.ts +11 -0
- package/dist/components/render-field-read-only.js +75 -0
- package/dist/components/render-field-reference.d.ts +15 -0
- package/dist/components/render-field-reference.js +103 -0
- package/dist/components/render-field-scalar-inputs.d.ts +10 -0
- package/dist/components/render-field-scalar-inputs.js +35 -0
- package/dist/components/render-field-text-inputs.d.ts +3 -0
- package/dist/components/render-field-text-inputs.js +34 -0
- package/dist/components/render-field-values.d.ts +18 -0
- package/dist/components/render-field-values.js +111 -0
- package/dist/components/render-field-write-only.d.ts +9 -0
- package/dist/components/render-field-write-only.js +29 -0
- package/dist/components/render-field.d.ts +2 -2
- package/dist/components/render-field.js +39 -670
- package/dist/components/render-list-header-slot.d.ts +5 -0
- package/dist/components/render-list-header-slot.js +30 -0
- package/dist/components/render-list-reference-bridge.d.ts +3 -0
- package/dist/components/render-list-reference-bridge.js +25 -0
- package/dist/components/render-list-reference-columns.d.ts +25 -0
- package/dist/components/render-list-reference-columns.js +137 -0
- package/dist/components/render-list-table-props.d.ts +52 -0
- package/dist/components/render-list-table-props.js +53 -0
- package/dist/components/render-list-toolbar-model.d.ts +42 -0
- package/dist/components/render-list-toolbar-model.js +82 -0
- package/dist/components/render-list-toolbar.d.ts +38 -0
- package/dist/components/render-list-toolbar.js +40 -0
- package/dist/components/render-list-types.d.ts +113 -0
- package/dist/components/render-list-types.js +1 -0
- package/dist/components/render-list.d.ts +4 -113
- package/dist/components/render-list.js +91 -307
- package/dist/components/use-reference-input-state.d.ts +20 -0
- package/dist/components/use-reference-input-state.js +85 -0
- package/dist/components/use-render-edit-controlled-sync.d.ts +3 -0
- package/dist/components/use-render-edit-controlled-sync.js +26 -0
- package/dist/components/use-render-edit-draft-save.d.ts +29 -0
- package/dist/components/use-render-edit-draft-save.js +141 -0
- package/dist/components/use-render-edit-form.d.ts +10 -0
- package/dist/components/use-render-edit-form.js +101 -0
- package/dist/components/use-render-edit-sections.d.ts +15 -0
- package/dist/components/use-render-edit-sections.js +100 -0
- package/dist/components/use-render-edit-state.d.ts +65 -0
- package/dist/components/use-render-edit-state.js +158 -0
- package/dist/components/use-render-edit-steps.d.ts +13 -0
- package/dist/components/use-render-edit-steps.js +52 -0
- package/dist/components/use-render-list-columns.d.ts +18 -0
- package/dist/components/use-render-list-columns.js +41 -0
- package/dist/components/use-render-list-search.d.ts +4 -0
- package/dist/components/use-render-list-search.js +50 -0
- package/dist/primitives.d.ts +1 -1
- package/dist/web/app/dashboard-body.d.ts +1 -1
- package/dist/web/app/dashboard-body.js +42 -246
- package/dist/web/app/dashboard-header.d.ts +34 -0
- package/dist/web/app/dashboard-header.js +58 -0
- package/dist/web/app/dashboard-screen-controls.d.ts +11 -0
- package/dist/web/app/dashboard-screen-controls.js +42 -0
- package/dist/web/app/dashboard-screen-tiles.d.ts +8 -0
- package/dist/web/app/dashboard-screen-tiles.js +42 -0
- package/dist/web/app/dashboard-visibility.d.ts +42 -0
- package/dist/web/app/dashboard-visibility.js +102 -0
- package/dist/web/app/use-dashboard-screen-state.d.ts +35 -0
- package/dist/web/app/use-dashboard-screen-state.js +69 -0
- package/dist/web/primitives/data-table/actions-column.js +6 -5
- package/dist/web/primitives/data-table/card-body.d.ts +6 -0
- package/dist/web/primitives/data-table/card-body.js +32 -0
- package/dist/web/primitives/data-table/card-row.d.ts +5 -0
- package/dist/web/primitives/data-table/card-row.js +31 -0
- package/dist/web/primitives/data-table/card-view.d.ts +3 -0
- package/dist/web/primitives/data-table/card-view.js +13 -0
- package/dist/web/primitives/data-table/column-resize-handle.js +3 -1
- package/dist/web/primitives/data-table/data-cell-props.d.ts +7 -0
- package/dist/web/primitives/data-table/data-cell-props.js +8 -0
- package/dist/web/primitives/data-table/row-render-parts.d.ts +8 -0
- package/dist/web/primitives/data-table/row-render-parts.js +35 -0
- package/dist/web/primitives/data-table/table-body.d.ts +3 -0
- package/dist/web/primitives/data-table/table-body.js +32 -0
- package/dist/web/primitives/data-table/table-content.d.ts +3 -0
- package/dist/web/primitives/data-table/table-content.js +41 -0
- package/dist/web/primitives/data-table/table-data-cell.d.ts +4 -0
- package/dist/web/primitives/data-table/table-data-cell.js +25 -0
- package/dist/web/primitives/data-table/table-header-row.d.ts +3 -0
- package/dist/web/primitives/data-table/table-header-row.js +21 -0
- package/dist/web/primitives/data-table/table-view.d.ts +3 -0
- package/dist/web/primitives/data-table/table-view.js +25 -0
- package/dist/web/primitives/data-table/toolbar.d.ts +6 -0
- package/dist/web/primitives/data-table/toolbar.js +53 -0
- package/dist/web/primitives/data-table/use-data-table-state.d.ts +44 -0
- package/dist/web/primitives/data-table/use-data-table-state.js +123 -0
- package/dist/web/primitives/data-table.d.ts +3 -4
- package/dist/web/primitives/data-table.js +32 -252
- package/dist/web/primitives/file-value-list.js +1 -1
- package/dist/web/primitives/form-footer-parts.d.ts +25 -0
- package/dist/web/primitives/form-footer-parts.js +55 -0
- package/dist/web/primitives/form.js +33 -43
- package/dist/web/primitives/input-choice-kinds.d.ts +4 -0
- package/dist/web/primitives/input-choice-kinds.js +67 -0
- package/dist/web/primitives/input-file-kinds.d.ts +3 -0
- package/dist/web/primitives/input-file-kinds.js +6 -0
- package/dist/web/primitives/input-number-kinds.d.ts +4 -0
- package/dist/web/primitives/input-number-kinds.js +16 -0
- package/dist/web/primitives/input-optional-props.d.ts +20 -0
- package/dist/web/primitives/input-optional-props.js +24 -0
- package/dist/web/primitives/input-select-presentation.d.ts +13 -0
- package/dist/web/primitives/input-select-presentation.js +19 -0
- package/dist/web/primitives/input-shared.d.ts +22 -0
- package/dist/web/primitives/input-shared.js +10 -0
- package/dist/web/primitives/input-temporal-kinds.d.ts +5 -0
- package/dist/web/primitives/input-temporal-kinds.js +10 -0
- package/dist/web/primitives/input-text-kinds.d.ts +7 -0
- package/dist/web/primitives/input-text-kinds.js +33 -0
- package/dist/web/primitives/input.js +35 -161
- package/dist/web/widgets/chart-data.d.ts +35 -0
- package/dist/web/widgets/chart-data.js +37 -0
- package/dist/web/widgets/chart-markers.d.ts +26 -0
- package/dist/web/widgets/chart-markers.js +64 -0
- package/dist/web/widgets/chart-parts.d.ts +20 -0
- package/dist/web/widgets/chart-parts.js +15 -0
- package/dist/web/widgets/charts.d.ts +5 -78
- package/dist/web/widgets/charts.js +7 -485
- package/dist/web/widgets/drawer-panel-class.d.ts +3 -0
- package/dist/web/widgets/drawer-panel-class.js +40 -0
- package/dist/web/widgets/drawer-resize-controls.d.ts +11 -0
- package/dist/web/widgets/drawer-resize-controls.js +12 -0
- package/dist/web/widgets/drawer-sizing.d.ts +19 -0
- package/dist/web/widgets/drawer-sizing.js +60 -0
- package/dist/web/widgets/drawer.js +17 -116
- package/dist/web/widgets/stacked-area-brush.d.ts +13 -0
- package/dist/web/widgets/stacked-area-brush.js +91 -0
- package/dist/web/widgets/stacked-area-chart.d.ts +6 -0
- package/dist/web/widgets/stacked-area-chart.js +45 -0
- package/dist/web/widgets/stacked-area-chrome.d.ts +7 -0
- package/dist/web/widgets/stacked-area-chrome.js +77 -0
- package/dist/web/widgets/stacked-area-geometry.d.ts +54 -0
- package/dist/web/widgets/stacked-area-geometry.js +83 -0
- package/dist/web/widgets/stacked-area-model.d.ts +11 -0
- package/dist/web/widgets/stacked-area-model.js +1 -0
- package/dist/web/widgets/stacked-area-plot.d.ts +3 -0
- package/dist/web/widgets/stacked-area-plot.js +51 -0
- package/dist/web/widgets/stacked-area-props.d.ts +83 -0
- package/dist/web/widgets/stacked-area-props.js +16 -0
- package/dist/web/widgets/stacked-area-svg.d.ts +3 -0
- package/dist/web/widgets/stacked-area-svg.js +38 -0
- package/dist/web/widgets/stacked-area-tooltip.d.ts +3 -0
- package/dist/web/widgets/stacked-area-tooltip.js +86 -0
- package/dist/web/widgets/use-drawer-resize.d.ts +22 -0
- package/dist/web/widgets/use-drawer-resize.js +52 -0
- package/dist/web/widgets/use-stacked-area-tooltip.d.ts +9 -0
- package/dist/web/widgets/use-stacked-area-tooltip.js +34 -0
- package/dist/web/widgets/use-stacked-area-window.d.ts +20 -0
- package/dist/web/widgets/use-stacked-area-window.js +76 -0
- package/package.json +9 -9
- package/src/changes.json +54 -0
|
@@ -1,1135 +1,70 @@
|
|
|
1
|
-
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { PROJECTION_DETAIL_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import { ErrorReference } from "../app/error-reference.js";
|
|
5
|
-
import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
|
|
6
1
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, formDerivation, actionFormScreenQn, } = props;
|
|
31
|
-
// View mode is a locked form, so everything keyed on `disabled` follows.
|
|
32
|
-
const disabled = disabledProp || viewMode;
|
|
33
|
-
const i18nScreenId = props.i18nScreenId ?? screen.id;
|
|
34
|
-
const { customSubmit } = props;
|
|
35
|
-
// Translate fallback: when the caller does not provide a translate function,
|
|
36
|
-
// consume the i18next context directly. Otherwise field labels would remain raw
|
|
37
|
-
// keys (`feature:entity:foo:field:title`) without caller wiring.
|
|
38
|
-
// useTranslation throws without a LocaleProvider, which is acceptable because
|
|
39
|
-
// RenderEdit only runs inside a mounted Kumiko app tree.
|
|
40
|
-
const t = useTranslation();
|
|
41
|
-
const translate = translateProp ?? t;
|
|
42
|
-
const dispatcher = useOptionalDispatcher();
|
|
43
|
-
const isWizard = screen.layout.mode === "wizard";
|
|
44
|
-
// Tabs reuse the wizard's step machinery: one section visible at a time,
|
|
45
|
-
// all of them mounted, `currentStep` as the active index. What they do NOT
|
|
46
|
-
// share is navigation — a tab form submits from any tab, so every
|
|
47
|
-
// step-transition path below stays wizard-only.
|
|
48
|
-
const isTabs = screen.layout.mode === "tabs";
|
|
49
|
-
const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
|
|
50
|
-
const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
|
|
51
|
-
const draftStorage = useDraftStorage();
|
|
52
|
-
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
53
|
-
const [linkCopied, setLinkCopied] = useState(false);
|
|
54
|
-
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
55
|
-
const [focusFirstErrorKey, setFocusFirstErrorKey] = useState(0);
|
|
56
|
-
const [formError, setFormError] = useState(null);
|
|
57
|
-
// Only a submit's validation/server error is cleared by editing; a delete
|
|
58
|
-
// rejection and a version conflict (reload is the way out) stay put.
|
|
59
|
-
const [formErrorSource, setFormErrorSource] = useState("submit");
|
|
60
|
-
// A rejected delete can carry several blocking reasons; the first one is the
|
|
61
|
-
// banner's headline (formError), the rest are listed beneath it.
|
|
62
|
-
const [extraDeleteIssues, setExtraDeleteIssues] = useState([]);
|
|
63
|
-
// One state for all header actions (actions?), not per-button — only one
|
|
64
|
-
// action can be in flight at a time, and this keeps the error surfaced in
|
|
65
|
-
// the shared formError-adjacent banner region instead of inside the
|
|
66
|
-
// button row: a full-width Banner there breaks the
|
|
67
|
-
// flex justify-end action bar.
|
|
68
|
-
const [actionError, setActionError] = useState(null);
|
|
69
|
-
const [rawStep, setRawStep] = useState(0);
|
|
70
|
-
const [passedStepKeys, setPassedStepKeys] = useState(() => new Set());
|
|
71
|
-
const [extensionStepComplete, setExtensionStepComplete] = useState({});
|
|
72
|
-
const reportExtensionStepComplete = useCallback((stepKey, complete) => {
|
|
73
|
-
setExtensionStepComplete((prev) => (prev[stepKey] === true) === complete ? prev : { ...prev, [stepKey]: complete });
|
|
74
|
-
}, []);
|
|
75
|
-
// Tabs have stable ids, so the active tab follows its id: a field change
|
|
76
|
-
// that hides an earlier tab must not shift the index onto another tab.
|
|
77
|
-
const [activeTabId, setActiveTabId] = useState(undefined);
|
|
78
|
-
const draftState = useWizardFormDraftState({
|
|
79
|
-
screenId: screen.id,
|
|
80
|
-
entityId: entityIdProp,
|
|
81
|
-
isCreateMode,
|
|
82
|
-
draftStorage,
|
|
83
|
-
});
|
|
84
|
-
const { draftCandidates, setDraftCandidates } = draftState;
|
|
85
|
-
// Composed save: extension sections report their dirty state so the Save button
|
|
86
|
-
// activates when only a section changed, and provide a submit handler that runs
|
|
87
|
-
// after the entity write. extensionErrorKey stores the i18n key for a failed
|
|
88
|
-
// section persistence.
|
|
89
|
-
//
|
|
90
|
-
// This is not a second write path for saveDraft()'s data: deriveFormFields
|
|
91
|
-
// skips extension sections entirely, so extension-owned field state never enters
|
|
92
|
-
// `fields` or `controller.getSnapshot().values`; there is no draft-covered state
|
|
93
|
-
// left for persistExtensions() to compete over.
|
|
94
|
-
const [extensionDirty, setExtensionDirty] = useState(false);
|
|
95
|
-
// Mirror for the leave guard: it must see "saved" before React re-renders.
|
|
96
|
-
const extensionDirtyRef = useRef(false);
|
|
97
|
-
const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
|
|
98
|
-
const [extensionErrorKey, setExtensionErrorKey] = useState(null);
|
|
99
|
-
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(({ anyDirty, hasRegistrations }) => {
|
|
100
|
-
extensionDirtyRef.current = anyDirty;
|
|
101
|
-
setExtensionDirty(anyDirty);
|
|
102
|
-
setHasExtensionRegistrations(hasRegistrations);
|
|
103
|
-
});
|
|
104
|
-
const { Button, Banner, Card, Dialog, Form, Section, Grid, GridCell, Text, Progress, StepBar, WizardStepGroup, Tabs, PageHeader, ActionOverflowMenu, } = usePrimitives();
|
|
105
|
-
const { toMenuItem: menuActionToItem, confirmDialog: menuActionConfirmDialog } = useActionMenuItems({ Dialog, onError: setActionError });
|
|
106
|
-
const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
|
|
107
|
-
const pageHeaderCompact = usePageHeaderCompact();
|
|
108
|
-
const insideDrawer = useInsideDrawer();
|
|
109
|
-
// Both stepped layouts show one section at a time and keep the rest mounted
|
|
110
|
-
// but inert. A tabs layout on a host without the Tabs primitive has no strip
|
|
111
|
-
// to switch with, so it falls back to rendering every section stacked rather
|
|
112
|
-
// than stranding the user on section 0. Same fallback when the
|
|
113
|
-
// WizardStepGroup wrapper is missing.
|
|
114
|
-
const isStepped = isWizard || (isTabs && Tabs !== undefined && WizardStepGroup !== undefined);
|
|
115
|
-
const fields = useMemo(() => deriveFormFields(screen), [screen]);
|
|
116
|
-
// Must be computed before submitConfig/useForm bakes it in (the controller
|
|
117
|
-
// freezes it on first render). This is the unfiltered field-name set from
|
|
118
|
-
// `fieldsFilter` ∩ `fields` — not the value-dependent `filteredSections`
|
|
119
|
-
// (which also drops `section.visible === false`). Hidden fields still fall
|
|
120
|
-
// out via form-controller's `hiddenFields` path. undefined = unscoped, since
|
|
121
|
-
// scoping would silently drop root-level .refine() issues.
|
|
122
|
-
// A `fieldsFilter` that matches nothing in `fields` (typo, renamed field)
|
|
123
|
-
// must fall back to unscoped too — an empty scope array would filter out
|
|
124
|
-
// ALL validation issues, letting submit() through
|
|
125
|
-
// unvalidated on a form that also renders no sections.
|
|
126
|
-
const scopeFieldNames = useMemo(() => {
|
|
127
|
-
if (fieldsFilter === undefined)
|
|
128
|
-
return undefined;
|
|
129
|
-
const matched = fieldsFilter.filter((f) => Object.hasOwn(fields, f));
|
|
130
|
-
return matched.length === 0 ? undefined : matched;
|
|
131
|
-
}, [fieldsFilter, fields]);
|
|
132
|
-
// Configure submission only when the caller provides a writeCommand; with
|
|
133
|
-
// customSubmit, the form controller has no submit wiring because
|
|
134
|
-
// controller.submit() is not called.
|
|
135
|
-
const writeVerb = isCreateMode ? "create" : "update";
|
|
136
|
-
// A write-excluded field must never reach the payload: the handler schema is
|
|
137
|
-
// z.never() for it, so even an unchanged read-only value would be rejected.
|
|
138
|
-
const omitFields = useMemo(() => [
|
|
139
|
-
...layoutEditFields(screen)
|
|
140
|
-
.filter((spec) => spec.submit === false)
|
|
141
|
-
.map((spec) => spec.field),
|
|
142
|
-
...(screen.writeExcludedFields?.[writeVerb] ?? []),
|
|
143
|
-
], [screen, writeVerb]);
|
|
144
|
-
const submitConfig = writeCommand !== undefined
|
|
145
|
-
? {
|
|
146
|
-
type: writeCommand,
|
|
147
|
-
payloadMode,
|
|
148
|
-
...(buildPayload !== undefined && { buildPayload }),
|
|
149
|
-
...(omitFields.length > 0 && { omitFields }),
|
|
150
|
-
...(serverFieldPathPrefix !== undefined && { serverFieldPathPrefix }),
|
|
151
|
-
...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
|
|
152
|
-
}
|
|
153
|
-
: undefined;
|
|
154
|
-
const { controller, snapshot } = useForm({
|
|
155
|
-
initial,
|
|
156
|
-
fields,
|
|
157
|
-
...(schema !== undefined && { schema }),
|
|
158
|
-
...(ctx !== undefined && { ctx }),
|
|
159
|
-
...(submitConfig !== undefined && { submit: submitConfig }),
|
|
160
|
-
});
|
|
161
|
-
// Derived fields are written into form state (not only displayed) so required
|
|
162
|
-
// validation and the submit payload carry them; only a real change is set, which
|
|
163
|
-
// keeps this from looping.
|
|
164
|
-
useEffect(() => {
|
|
165
|
-
if (formDerivation === undefined)
|
|
166
|
-
return;
|
|
167
|
-
const patch = changedDerivedValues(snapshot.values, formDerivation);
|
|
168
|
-
if (patch !== undefined)
|
|
169
|
-
controller.setValues(patch); // @cast-boundary form-values
|
|
170
|
-
}, [snapshot.values, formDerivation, controller]);
|
|
171
|
-
// Wired at the user-input boundary, not in controller.setField: computed
|
|
172
|
-
// fields, prefill, draft restore and action patches also call setValues
|
|
173
|
-
// after a failed submit and must not wipe the errors.
|
|
174
|
-
const clearErrorsOnInput = (changedField) => {
|
|
175
|
-
const errors = controller.getSnapshot().errors;
|
|
176
|
-
const remaining = issuesAfterEdit(errors, changedField);
|
|
177
|
-
if (remaining !== errors)
|
|
178
|
-
controller.setErrors(remaining);
|
|
179
|
-
if (formErrorSource === "submit" && formError?.code !== "version_conflict") {
|
|
180
|
-
setFormError(null);
|
|
181
|
-
}
|
|
182
|
-
};
|
|
183
|
-
const lastRefreshTokenRef = useRef(refreshToken);
|
|
184
|
-
useEffect(() => {
|
|
185
|
-
if (lastRefreshTokenRef.current === refreshToken)
|
|
186
|
-
return;
|
|
187
|
-
lastRefreshTokenRef.current = refreshToken;
|
|
188
|
-
// Never overwrite user input: a dirty form keeps its values.
|
|
189
|
-
if (controller.getSnapshot().isDirty)
|
|
190
|
-
return;
|
|
191
|
-
controller.setValues(initial);
|
|
192
|
-
controller.rebase();
|
|
193
|
-
}, [refreshToken, controller, initial]);
|
|
194
|
-
// Controlled mode. Both callbacks live in refs so a
|
|
195
|
-
// re-rendering caller (identity-unstable `onChange`/`onControlsReady`
|
|
196
|
-
// closures, e.g. inline arrows) doesn't retrigger these effects — only a
|
|
197
|
-
// real snapshot mutation (typing, patch()) does. Without this, a caller
|
|
198
|
-
// whose onChange calls patch() to fill other fields (the VIN-decode use
|
|
199
|
-
// case) would loop: patch → new snapshot → effect
|
|
200
|
-
// re-subscribes with a "new" onChange → refires.
|
|
201
|
-
const onChangeRef = useRef(onChange);
|
|
202
|
-
onChangeRef.current = onChange;
|
|
203
|
-
// Guards against redelivering to the SAME callback identity on every
|
|
204
|
-
// render (an inline-arrow onControlsReady would otherwise refire the
|
|
205
|
-
// effect below on every render since the callback itself is now a dep).
|
|
206
|
-
// Tracks the actual prop, not a ref snapshot, so a caller that swaps in a
|
|
207
|
-
// real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
|
|
208
|
-
// gets delivered to for THIS mount instead of never.
|
|
209
|
-
const deliveredControlsToRef = useRef(undefined);
|
|
210
|
-
const onControlsReadyRef = useRef(onControlsReady);
|
|
211
|
-
onControlsReadyRef.current = onControlsReady;
|
|
212
|
-
const hasNavigatedRef = useRef(false);
|
|
213
|
-
const submittedRef = useRef(false);
|
|
214
|
-
const isSubmittingRef = useRef(false);
|
|
215
|
-
const scopeFieldNamesRef = useRef(scopeFieldNames);
|
|
216
|
-
scopeFieldNamesRef.current = scopeFieldNames;
|
|
217
|
-
const scopedValidate = useCallback(() => controller.validate(scopeFieldNamesRef.current), [controller]);
|
|
218
|
-
// The extension dirty flag aggregates every section; each section clears its
|
|
219
|
-
// own part, so a host-side markSaved must not reset it.
|
|
220
|
-
const markSaved = useCallback((fields) => {
|
|
221
|
-
if (fields === undefined)
|
|
222
|
-
controller.rebase();
|
|
223
|
-
else
|
|
224
|
-
controller.rebaseFields(fields);
|
|
225
|
-
}, [controller]);
|
|
226
|
-
// `saveDraft` (defined below) is a fresh closure every render, over that
|
|
227
|
-
// render's `draftKey`/`draftId` — the ref lets the debounce timer below
|
|
228
|
-
// always call the CURRENT one even though the timer was scheduled by an
|
|
229
|
-
// earlier render's patch() call. `currentStepRef` gives it the current
|
|
230
|
-
// wizard step without depending on `currentStep` (computed further down,
|
|
231
|
-
// from `filteredSections`) at the point patchAndScheduleDraftSave itself
|
|
232
|
-
// is defined — kept fresh where `currentStep` is computed below.
|
|
233
|
-
const saveDraftRef = useRef(() => { });
|
|
234
|
-
// `handleSubmit` (defined below) is a fresh closure over this render's
|
|
235
|
-
// snapshot/extensionDirty, while the onControlsReady effect fires once per
|
|
236
|
-
// mount — the ref lets controls.submit() always reach the current one.
|
|
237
|
-
const handleSubmitRef = useRef(async () => { });
|
|
238
|
-
const wizardNextRef = useRef(() => { });
|
|
239
|
-
const wizardBackRef = useRef(() => { });
|
|
240
|
-
const currentStepRef = useRef(0);
|
|
241
|
-
const draftSaveTimerRef = useRef(null);
|
|
242
|
-
useEffect(() => {
|
|
243
|
-
return () => {
|
|
244
|
-
if (draftSaveTimerRef.current !== null) {
|
|
245
|
-
clearTimeout(draftSaveTimerRef.current);
|
|
246
|
-
// Flush the pending debounced save instead of dropping it — a
|
|
247
|
-
// navigation/unmount inside the debounce window would otherwise
|
|
248
|
-
// silently discard the last patch() (discardDraft nulls this ref
|
|
249
|
-
// first on the post-submit path, so this can't resurrect a
|
|
250
|
-
// just-submitted draft).
|
|
251
|
-
saveDraftRef.current(currentStepRef.current);
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
|
-
}, []);
|
|
255
|
-
// patch() (controlled mode, extension sections) applies immediately —
|
|
256
|
-
// only the resulting draft save is debounced, so a patch() burst
|
|
257
|
-
// (VIN-decode filling several fields, or onChange fanning a keystroke out
|
|
258
|
-
// through patch(), see the VIN-decode test above) collapses into one save.
|
|
259
|
-
const patchAndScheduleDraftSave = useCallback((partial) => {
|
|
260
|
-
controller.setValues(partial);
|
|
261
|
-
if (!draftEnabled || dispatcher === undefined || disabled || submittedRef.current)
|
|
262
|
-
return;
|
|
263
|
-
if (draftSaveTimerRef.current !== null)
|
|
264
|
-
clearTimeout(draftSaveTimerRef.current);
|
|
265
|
-
draftSaveTimerRef.current = setTimeout(() => {
|
|
266
|
-
draftSaveTimerRef.current = null;
|
|
267
|
-
if (disabled || submittedRef.current)
|
|
268
|
-
return;
|
|
269
|
-
saveDraftRef.current(currentStepRef.current);
|
|
270
|
-
}, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
|
|
271
|
-
}, [controller, draftEnabled, dispatcher, disabled]);
|
|
272
|
-
// Runs before the onChange effect below (declaration order = React
|
|
273
|
-
// execution order) so that an onChange fired on mount always sees
|
|
274
|
-
// `controls !== undefined` — both effects depend only on mount-stable
|
|
275
|
-
// values (controller/scopedValidate/patchAndScheduleDraftSave), so the
|
|
276
|
-
// swap is otherwise a no-op. See the VIN-decode test: an initial
|
|
277
|
-
// value that should derive dependent fields on mount needs controls.patch
|
|
278
|
-
// available on the very first onChange call, not just from the second
|
|
279
|
-
// keystroke onward.
|
|
280
|
-
const hasControlsReady = onControlsReady !== undefined;
|
|
281
|
-
useEffect(() => {
|
|
282
|
-
if (!hasControlsReady)
|
|
283
|
-
return;
|
|
284
|
-
const ready = onControlsReadyRef.current;
|
|
285
|
-
if (ready === undefined)
|
|
286
|
-
return;
|
|
287
|
-
if (deliveredControlsToRef.current === ready)
|
|
288
|
-
return;
|
|
289
|
-
deliveredControlsToRef.current = ready;
|
|
290
|
-
ready({
|
|
291
|
-
patch: patchAndScheduleDraftSave,
|
|
292
|
-
validate: scopedValidate,
|
|
293
|
-
getValues: () => controller.getSnapshot().values,
|
|
294
|
-
// saveNow: a host-driven submit must save on a wizard's intermediate
|
|
295
|
-
// steps too instead of silently advancing; stepping is next()/back().
|
|
296
|
-
submit: () => handleSubmitRef.current({ saveNow: true }),
|
|
297
|
-
next: () => wizardNextRef.current(),
|
|
298
|
-
back: () => wizardBackRef.current(),
|
|
299
|
-
});
|
|
300
|
-
// Dep on boolean presence only — inline-arrow identity must not redeliver.
|
|
301
|
-
}, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
|
|
302
|
-
// `schema` is typically an inline caller prop (`schema={z.object({...})}`)
|
|
303
|
-
// with unstable identity across renders — as an effect dep it would refire
|
|
304
|
-
// on every parent render, not just on a snapshot change, risking a loop if
|
|
305
|
-
// the caller's onChange triggers a parent re-render. Held in a ref like
|
|
306
|
-
// onChangeRef so only a real snapshot mutation retriggers this effect.
|
|
307
|
-
const schemaRef = useRef(schema);
|
|
308
|
-
schemaRef.current = schema;
|
|
309
|
-
// Controls are guaranteed ready by the time this fires (see the
|
|
310
|
-
// onControlsReady effect above, which is declared first on purpose).
|
|
311
|
-
useEffect(() => {
|
|
312
|
-
const cb = onChangeRef.current;
|
|
313
|
-
if (cb === undefined)
|
|
314
|
-
return;
|
|
315
|
-
// Dry-run parse against `schema` — NOT controller.validate(). Calling
|
|
316
|
-
// validate() here would write field-level errors into snapshot.errors
|
|
317
|
-
// on every keystroke, painting error messages while the user is still
|
|
318
|
-
// typing. `valid` can therefore legitimately diverge from what's
|
|
319
|
-
// currently rendered under the fields (the last *mutating* validate()
|
|
320
|
-
// call, e.g. from controls.validate() or submit()). The issues are
|
|
321
|
-
// filtered exactly like submit()'s validate: hidden fields and fields
|
|
322
|
-
// outside the `fields` scope never make the form invalid.
|
|
323
|
-
const currentSchema = schemaRef.current;
|
|
324
|
-
const valid = currentSchema === undefined ||
|
|
325
|
-
dryRunFieldIssues(currentSchema, snapshot, scopeFieldNamesRef.current, true).length === 0;
|
|
326
|
-
cb({
|
|
327
|
-
values: snapshot.values,
|
|
328
|
-
changes: snapshot.changes,
|
|
329
|
-
dirty: snapshot.isDirty,
|
|
330
|
-
valid,
|
|
331
|
-
submitting: isSubmitting,
|
|
332
|
-
});
|
|
333
|
-
}, [snapshot, isSubmitting]);
|
|
334
|
-
const onDirtyChangeRef = useRef(onDirtyChange);
|
|
335
|
-
onDirtyChangeRef.current = onDirtyChange;
|
|
336
|
-
const hasUnsavedInput = snapshot.isDirty || extensionDirty;
|
|
337
|
-
useEffect(() => {
|
|
338
|
-
onDirtyChangeRef.current?.(hasUnsavedInput);
|
|
339
|
-
}, [hasUnsavedInput]);
|
|
340
|
-
// Draft wizards keep their input server-side, so leaving loses nothing.
|
|
341
|
-
const persistsDraft = isWizard && screen.layout.draft === true;
|
|
342
|
-
useUnsavedInputSource(() => !persistsDraft && (controller.getSnapshot().isDirty || extensionDirtyRef.current));
|
|
343
|
-
useWizardFormDraftRestore({
|
|
344
|
-
screenId: screen.id,
|
|
345
|
-
isCreateMode,
|
|
346
|
-
draftEnabled,
|
|
347
|
-
dispatcher,
|
|
348
|
-
controller,
|
|
349
|
-
hasNavigatedRef,
|
|
350
|
-
setRawStep,
|
|
351
|
-
draftState,
|
|
2
|
+
import { renderEditLayout } from "./render-edit-layout.js";
|
|
3
|
+
import { deriveFooterFlags, deriveLayoutFlags } from "./render-edit-layout-flags.js";
|
|
4
|
+
import { resolveRenderEditProps, } from "./render-edit-render-model.js";
|
|
5
|
+
import { createHandleSubmit } from "./render-edit-submit.js";
|
|
6
|
+
import { deriveTitles } from "./render-edit-titles.js";
|
|
7
|
+
import { createWizardNavigation } from "./render-edit-wizard-navigation.js";
|
|
8
|
+
import { useRenderEditControlledSync } from "./use-render-edit-controlled-sync.js";
|
|
9
|
+
import { useRenderEditDraftSave } from "./use-render-edit-draft-save.js";
|
|
10
|
+
import { useRenderEditForm } from "./use-render-edit-form.js";
|
|
11
|
+
import { useRenderEditSections } from "./use-render-edit-sections.js";
|
|
12
|
+
import { useRenderEditState } from "./use-render-edit-state.js";
|
|
13
|
+
import { useRenderEditSteps } from "./use-render-edit-steps.js";
|
|
14
|
+
import { buildWizardDraftActions } from "./use-wizard-form-draft.js";
|
|
15
|
+
function footerSlotName(screen) {
|
|
16
|
+
const footerSlot = screen.slots?.footer;
|
|
17
|
+
return footerSlot !== undefined ? extensionSectionName(footerSlot) : undefined;
|
|
18
|
+
}
|
|
19
|
+
export function RenderEdit(rawProps) {
|
|
20
|
+
const props = resolveRenderEditProps(rawProps);
|
|
21
|
+
const state = useRenderEditState({
|
|
22
|
+
screen: props.screen,
|
|
23
|
+
entityId: props.entityId,
|
|
24
|
+
translate: props.translate,
|
|
352
25
|
});
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
writeVerb,
|
|
372
|
-
isCreateMode,
|
|
373
|
-
entityIdProp,
|
|
374
|
-
]);
|
|
375
|
-
const filteredSections = useMemo(
|
|
376
|
-
// A fully-hidden "fields" section (every field in it currently
|
|
377
|
-
// condition-hidden) must not occupy a wizard step; it would render
|
|
378
|
-
// empty and block Back/Next on nothing. Extension, relatedList and
|
|
379
|
-
// writeForm sections carry no `visible` (they own their own lifecycle /
|
|
380
|
-
// run their own query / own submit), so they always pass through. A
|
|
381
|
-
// section with no fields at all (e.g. a review-only step) has
|
|
382
|
-
// `visible: fields.some(...)` = false vacuously; that's "no fields to
|
|
383
|
-
// hide", not "hidden", so it stays too. Unless the create
|
|
384
|
-
// exclusion emptied it: that would leave an empty wizard step / tab.
|
|
385
|
-
() => filterEditSections(vm.sections, fieldsFilter).filter((section) => section.kind === "extension" ||
|
|
386
|
-
section.kind === "relatedList" ||
|
|
387
|
-
section.kind === "writeForm" ||
|
|
388
|
-
(section.fields.length === 0 && section.emptiedByWriteExclusion !== true) ||
|
|
389
|
-
section.visible), [vm.sections, fieldsFilter]);
|
|
390
|
-
const allFieldsRequired = useMemo(() => {
|
|
391
|
-
const fillable = filteredSections.flatMap((section) => section.kind === "fields"
|
|
392
|
-
? section.fields.filter((field) => field.visible && !field.readOnly && field.type !== "boolean")
|
|
393
|
-
: []);
|
|
394
|
-
return fillable.length > 0 && fillable.every((field) => field.required);
|
|
395
|
-
}, [filteredSections]);
|
|
396
|
-
const showsAllRequiredHint = allFieldsRequired && hideSectionTitles !== true && !isWizard;
|
|
397
|
-
// true when editable fields exist or an extension opted into composed submit.
|
|
398
|
-
const isFormEditable = hasEditableSection(filteredSections);
|
|
399
|
-
// A fieldless form (input-less secretMint mint — the secret is
|
|
400
|
-
// server-generated, the mint step is only its submit button) has no
|
|
401
|
-
// section to gate on: it IS the action, so it always shows the submit
|
|
402
|
-
// button, and it can never go dirty either.
|
|
403
|
-
const isFieldless = Object.keys(entity.fields).length === 0;
|
|
404
|
-
// A lone relatedList tab (`hideSectionTitles` is only ever set by
|
|
405
|
-
// ProjectionDetailBody, which hands this component ONE section — its
|
|
406
|
-
// synthesized screen drops layout.mode, so an entityEdit tabs layout never
|
|
407
|
-
// reaches this line and `filteredSections[0]` stays the active section)
|
|
408
|
-
// needs its own tab panel capped at the available height so its table
|
|
409
|
-
// scrolls inside the panel instead of the whole page stretching to the
|
|
410
|
-
// row count — a short table still sizes to its content instead
|
|
411
|
-
// of stretching the panel to the bottom. Any other layout —
|
|
412
|
-
// multiple sections, a non-relatedList tab, stacked (non-tabs) forms —
|
|
413
|
-
// keeps normal document-flow height untouched.
|
|
414
|
-
const fillHeight = fillScreenHeight === true ||
|
|
415
|
-
(hideSectionTitles === true && filteredSections[0]?.kind === "relatedList");
|
|
416
|
-
// A lone writeForm tab is the whole page, so its submit joins this form's
|
|
417
|
-
// footer (pinned to the window edge like every other screen's save)
|
|
418
|
-
// instead of sitting in the section's title row.
|
|
419
|
-
const isLoneWriteFormTab = hideSectionTitles === true && filteredSections[0]?.kind === "writeForm";
|
|
420
|
-
const [writeFormFooterAction, setWriteFormFooterAction] = useState(undefined);
|
|
421
|
-
// Persist all composed extension sections using the resolved entityId.
|
|
422
|
-
// false means a section failed and its i18n key is shown in the banner. Without
|
|
423
|
-
// entity context (create mode without a route id), there is nothing to write.
|
|
424
|
-
async function persistExtensions() {
|
|
425
|
-
const entityId = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
426
|
-
if (entityId === null)
|
|
427
|
-
return true;
|
|
428
|
-
const results = await runExtensionSubmits({ entityId });
|
|
429
|
-
const failed = results.find((r) => !r.isSuccess);
|
|
430
|
-
if (failed !== undefined) {
|
|
431
|
-
setExtensionErrorKey(failed.errorKey ?? "kumiko.form.extension.save-failed");
|
|
432
|
-
return false;
|
|
433
|
-
}
|
|
434
|
-
return true;
|
|
435
|
-
}
|
|
436
|
-
const lastStepIndex = Math.max(filteredSections.length - 1, 0);
|
|
437
|
-
// Clamped on read, not on write: section visibility is value-dependent, so a
|
|
438
|
-
// stored stepIndex can point past what is currently rendered — that lands on
|
|
439
|
-
// the last step instead of an empty one.
|
|
440
|
-
const activeTabIndex = isTabs && activeTabId !== undefined
|
|
441
|
-
? filteredSections.findIndex((s, i) => tabIdAt(s, i) === activeTabId)
|
|
442
|
-
: -1;
|
|
443
|
-
const currentStep = activeTabIndex !== -1 ? activeTabIndex : Math.min(rawStep, lastStepIndex);
|
|
444
|
-
// Kept fresh for patchAndScheduleDraftSave's debounce timer above, which
|
|
445
|
-
// is defined before `currentStep` exists (it depends on `filteredSections`,
|
|
446
|
-
// computed further up from `vm`) and so cannot close over it directly.
|
|
447
|
-
currentStepRef.current = currentStep;
|
|
448
|
-
const isLastWizardStep = currentStep >= lastStepIndex;
|
|
449
|
-
const wizardDoneSteps = useMemo(() => {
|
|
450
|
-
if (!isWizard || isCreateMode)
|
|
451
|
-
return undefined;
|
|
452
|
-
const parse = schema === undefined
|
|
453
|
-
? undefined
|
|
454
|
-
: dryRunParse(schema, { values: snapshot.values, fields: snapshot.fields });
|
|
455
|
-
return filteredSections.map((section, index) => {
|
|
456
|
-
if (passedStepKeys.has(wizardStepKey(section, index)))
|
|
457
|
-
return true;
|
|
458
|
-
if (section.kind === "fields")
|
|
459
|
-
return isFieldsStepFilled(section, parse);
|
|
460
|
-
return (section.kind === "extension" &&
|
|
461
|
-
extensionStepComplete[wizardStepKey(section, index)] === true);
|
|
462
|
-
});
|
|
463
|
-
}, [
|
|
464
|
-
isWizard,
|
|
465
|
-
isCreateMode,
|
|
466
|
-
filteredSections,
|
|
467
|
-
passedStepKeys,
|
|
468
|
-
extensionStepComplete,
|
|
469
|
-
schema,
|
|
470
|
-
snapshot.values,
|
|
471
|
-
snapshot.fields,
|
|
472
|
-
]);
|
|
473
|
-
const updateModeStepBarProps = wizardDoneSteps === undefined
|
|
474
|
-
? {}
|
|
475
|
-
: { doneSteps: wizardDoneSteps, selectableSteps: "all" };
|
|
476
|
-
function jumpToStep(index) {
|
|
477
|
-
setRawStep(index);
|
|
478
|
-
if (isTabs)
|
|
479
|
-
setActiveTabId(tabIdAt(filteredSections[index], index));
|
|
480
|
-
}
|
|
481
|
-
const { adoptDraft, saveDraft, discardDraft } = buildWizardDraftActions({
|
|
482
|
-
screenId: screen.id,
|
|
483
|
-
isCreateMode,
|
|
484
|
-
draftEnabled,
|
|
485
|
-
disabled,
|
|
486
|
-
dispatcher,
|
|
487
|
-
draftStorage,
|
|
488
|
-
controller,
|
|
489
|
-
sections: vm.sections,
|
|
490
|
-
draftSaveTimerRef,
|
|
491
|
-
draftState,
|
|
26
|
+
const form = useRenderEditForm(props, state);
|
|
27
|
+
const draft = useRenderEditDraftSave(props, state, form);
|
|
28
|
+
const core = { props, state, form, draft };
|
|
29
|
+
useRenderEditControlledSync(core);
|
|
30
|
+
const sections = useRenderEditSections(core);
|
|
31
|
+
const steps = useRenderEditSteps({ ...core, sections });
|
|
32
|
+
const stepped = { ...core, sections, steps };
|
|
33
|
+
const draftActions = buildWizardDraftActions({
|
|
34
|
+
screenId: props.screen.id,
|
|
35
|
+
isCreateMode: state.isCreateMode,
|
|
36
|
+
draftEnabled: state.draftEnabled,
|
|
37
|
+
disabled: props.disabled,
|
|
38
|
+
dispatcher: state.dispatcher,
|
|
39
|
+
draftStorage: state.draftStorage,
|
|
40
|
+
controller: form.controller,
|
|
41
|
+
sections: sections.vm.sections,
|
|
42
|
+
draftSaveTimerRef: draft.refs.draftSaveTimerRef,
|
|
43
|
+
draftState: state.draftState,
|
|
492
44
|
});
|
|
493
|
-
saveDraftRef.current = saveDraft;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
// navigation" — a disabled wizard must still be steppable so Back/Next
|
|
503
|
-
// reach every step. But neither validate() (would block on an empty
|
|
504
|
-
// required field nobody can fill in) nor saveDraft() (would mint a
|
|
505
|
-
// draftId / dispatch a write — exactly what `disabled` forbids) may run.
|
|
506
|
-
if (disabled) {
|
|
507
|
-
setRawStep(next);
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
// skip: the current step has field errors — no transition, no draft save.
|
|
511
|
-
if (currentStepHasFieldErrors())
|
|
512
|
-
return;
|
|
513
|
-
if (!isCreateMode) {
|
|
514
|
-
const leftSection = filteredSections[currentStep];
|
|
515
|
-
if (leftSection !== undefined) {
|
|
516
|
-
const leftKey = wizardStepKey(leftSection, currentStep);
|
|
517
|
-
setPassedStepKeys((prev) => (prev.has(leftKey) ? prev : new Set(prev).add(leftKey)));
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
setRawStep(next);
|
|
521
|
-
saveDraft(next);
|
|
522
|
-
}
|
|
523
|
-
function currentStepHasFieldErrors() {
|
|
524
|
-
const section = filteredSections[currentStep];
|
|
525
|
-
const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
|
|
526
|
-
return fieldNames.length > 0 && !controller.validate(fieldNames);
|
|
527
|
-
}
|
|
528
|
-
function handleWizardBack() {
|
|
529
|
-
handleWizardStepSelect(currentStep - 1);
|
|
530
|
-
}
|
|
531
|
-
// Update mode: the record already exists and the final Save validates the
|
|
532
|
-
// whole form and jumps to the first erroring step (findFirstErroringStep), so
|
|
533
|
-
// skipping steps forward is safe; leaving the current step still goes through
|
|
534
|
-
// its validate gate.
|
|
535
|
-
function handleWizardStepSelect(targetStep) {
|
|
536
|
-
if (targetStep === currentStep)
|
|
537
|
-
return;
|
|
538
|
-
const isForward = targetStep > currentStep;
|
|
539
|
-
// Create mode: a forward jump would skip the per-step validate() gate.
|
|
540
|
-
if (isForward && isCreateMode)
|
|
541
|
-
return;
|
|
542
|
-
if (isForward && !disabled && currentStepHasFieldErrors())
|
|
543
|
-
return;
|
|
544
|
-
const clampedTarget = Math.min(Math.max(targetStep, 0), lastStepIndex);
|
|
545
|
-
hasNavigatedRef.current = true;
|
|
546
|
-
setRawStep(clampedTarget);
|
|
547
|
-
if (!disabled)
|
|
548
|
-
saveDraft(clampedTarget);
|
|
549
|
-
}
|
|
550
|
-
// Stepped modes (wizard, tabs): locates the first step whose "fields"
|
|
551
|
-
// section contains one of the given field paths — matched on the path's
|
|
552
|
-
// first segment, since `FieldIssue.path`/`snapshot.errors` keys can be
|
|
553
|
-
// dotted (embedded-list rows, e.g. `tasks.2.title`) while a section's field
|
|
554
|
-
// name is always the plain top-level name. Returns undefined for a
|
|
555
|
-
// root-level issue (path `"(root)"`, see zod-bridge.ts) or one that matches
|
|
556
|
-
// no rendered field — callers must NOT suppress the error banner in that
|
|
557
|
-
// case, there is nowhere to jump the user to. Extension sections are not
|
|
558
|
-
// jump targets by design: they validate through the controlled-mode
|
|
559
|
-
// controls.validate() API and own no field specs to match against.
|
|
560
|
-
function findFirstErroringStep(fieldPaths) {
|
|
561
|
-
return findFirstErroringSectionIndex(filteredSections, fieldPaths);
|
|
562
|
-
}
|
|
563
|
-
async function handleSubmit(options) {
|
|
564
|
-
// Enter in the active step triggers the native form submit (Next is
|
|
565
|
-
// type="submit" for Enter support) — on intermediate steps that means
|
|
566
|
-
// "Next", not "Save". Checked BEFORE the `disabled` guard below:
|
|
567
|
-
// `disabled` means "no input/no write", not "no navigation" — a
|
|
568
|
-
// disabled wizard must still be steppable (handleWizardNext itself
|
|
569
|
-
// blocks validate()/saveDraft() while disabled). `saveNow` is the explicit
|
|
570
|
-
// "Save and close" button and skips the step advance.
|
|
571
|
-
if (isWizard && !isLastWizardStep && options?.saveNow !== true) {
|
|
572
|
-
handleWizardNext();
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
// Locked state: the submit button is visibly disabled, but a
|
|
576
|
-
// native form submit (Enter key) reaches this handler regardless of the
|
|
577
|
-
// button's disabled attribute — block it here too, not just in the UI.
|
|
578
|
-
if (disabled || submitBlocked)
|
|
579
|
-
return;
|
|
580
|
-
// Sync guard — React state `isSubmitting` is too late for double-clicks
|
|
581
|
-
// in the same tick (customSubmit has no submitInFlight of its own).
|
|
582
|
-
if (isSubmittingRef.current)
|
|
583
|
-
return;
|
|
584
|
-
isSubmittingRef.current = true;
|
|
585
|
-
setIsSubmitting(true);
|
|
586
|
-
setExtensionErrorKey(null);
|
|
587
|
-
try {
|
|
588
|
-
// Extension-only: only a section is dirty and the main form is unchanged.
|
|
589
|
-
// Skip the entity write (it would send an empty changes payload) and only
|
|
590
|
-
// run the section handlers.
|
|
591
|
-
if (snapshot.isUnchanged && extensionDirty) {
|
|
592
|
-
// Same discard as the main path: an extension-only save is still a
|
|
593
|
-
// successful submit, so the draft must not survive it.
|
|
594
|
-
if (await persistExtensions()) {
|
|
595
|
-
extensionDirtyRef.current = false;
|
|
596
|
-
submittedRef.current = true;
|
|
597
|
-
await discardDraft();
|
|
598
|
-
}
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
let result;
|
|
602
|
-
if (customSubmit !== undefined) {
|
|
603
|
-
// customSubmit path (e.g. configEdit, which performs a separate write per
|
|
604
|
-
// field). Validate on the client first, then call the caller; on success,
|
|
605
|
-
// explicitly rebase the form state because controller.submit() normally
|
|
606
|
-
// does this itself, and the controller otherwise knows nothing about a
|
|
607
|
-
// successful custom submission (isUnchanged would remain false).
|
|
608
|
-
//
|
|
609
|
-
// Read the snapshot directly from the controller rather than React state.
|
|
610
|
-
// With rapid fill-then-click input, React batching may not have committed
|
|
611
|
-
// state updates when the submit click fires. handleSubmit's closure could otherwise
|
|
612
|
-
// see stale snapshot.changes={} and perform no writes, then return success
|
|
613
|
-
// and rebase the form, making it appear that the data was saved.
|
|
614
|
-
// controller.getSnapshot() is always current: the controller is the
|
|
615
|
-
// source of truth, while React state is only a rendering mirror.
|
|
616
|
-
const valid = controller.validate(scopeFieldNames);
|
|
617
|
-
if (!valid) {
|
|
618
|
-
// Field order matters: validationBlocked:true is its own
|
|
619
|
-
// variant in the SubmitResult union (NOT mixed with data/error);
|
|
620
|
-
// TS only narrows cleanly without a discriminator fight.
|
|
621
|
-
const blocked = {
|
|
622
|
-
validationBlocked: true,
|
|
623
|
-
isSuccess: false,
|
|
624
|
-
};
|
|
625
|
-
result = blocked;
|
|
626
|
-
}
|
|
627
|
-
else {
|
|
628
|
-
result = await customSubmit(controller.getSnapshot());
|
|
629
|
-
if (result.isSuccess)
|
|
630
|
-
controller.rebase();
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
else {
|
|
634
|
-
result = await controller.submit();
|
|
635
|
-
}
|
|
636
|
-
// Form-level errors (without field-level details) go to the banner.
|
|
637
|
-
// Field errors flow via snapshot.errors into the individual fields.
|
|
638
|
-
let extensionsPersisted = true;
|
|
639
|
-
if (result.isSuccess) {
|
|
640
|
-
setFormError(null);
|
|
641
|
-
setExtraDeleteIssues([]);
|
|
642
|
-
// `isNoOp: true` (payloadMode "changes", pre-filled form submitted
|
|
643
|
-
// untouched) means controller.submit() never called dispatcher.write
|
|
644
|
-
// — nothing to discard, and discarding here would delete a draft the
|
|
645
|
-
// form never persisted. Awaited, not fire-and-forget:
|
|
646
|
-
// `onSubmit` typically navigates away and unmounts this form, which
|
|
647
|
-
// would abort an in-flight discard and leave the draft behind after
|
|
648
|
-
// a successful submit.
|
|
649
|
-
if (result.isNoOp !== true) {
|
|
650
|
-
submittedRef.current = true;
|
|
651
|
-
await discardDraft();
|
|
652
|
-
}
|
|
653
|
-
extensionsPersisted = await persistExtensions();
|
|
654
|
-
if (extensionsPersisted)
|
|
655
|
-
extensionDirtyRef.current = false;
|
|
656
|
-
}
|
|
657
|
-
else if (result.validationBlocked) {
|
|
658
|
-
setFocusFirstErrorKey((key) => key + 1);
|
|
659
|
-
// Root-level `.refine()`/cross-field issues from controller.validate()
|
|
660
|
-
// — same "jump to the erroring step" treatment as a server field
|
|
661
|
-
// error below, sourced from the freshly-validated snapshot.
|
|
662
|
-
if (isStepped) {
|
|
663
|
-
const step = findFirstErroringStep(Object.keys(controller.getSnapshot().errors));
|
|
664
|
-
if (step !== undefined)
|
|
665
|
-
jumpToStep(step);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
else {
|
|
669
|
-
const fieldIssues = result.error.details?.fields ?? [];
|
|
670
|
-
const issuePaths = fieldIssues.map((i) => i.path);
|
|
671
|
-
if (fieldIssues.length > 0)
|
|
672
|
-
setFocusFirstErrorKey((key) => key + 1);
|
|
673
|
-
// controller.submit() writes server issues into the controller itself;
|
|
674
|
-
// customSubmit bypasses it, so push them here to reach the fields.
|
|
675
|
-
if (customSubmit !== undefined && fieldIssues.length > 0) {
|
|
676
|
-
controller.setErrors(groupIssuesByPath(fieldIssues));
|
|
677
|
-
}
|
|
678
|
-
// A server field error on a step the form isn't currently showing is
|
|
679
|
-
// otherwise invisible — jump to the first step that contains one of
|
|
680
|
-
// the errored fields. For tabs this is what keeps the submit from
|
|
681
|
-
// blocking in silence, the failure mode the boot-validator used to
|
|
682
|
-
// prevent by rejecting the layout outright. An issue no
|
|
683
|
-
// rendered field can show (`version`, `id`, a root-level refine, a
|
|
684
|
-
// hidden field) keeps the banner, jump or not.
|
|
685
|
-
if (isStepped) {
|
|
686
|
-
const step = findFirstErroringStep(issuePaths);
|
|
687
|
-
if (step !== undefined)
|
|
688
|
-
jumpToStep(step);
|
|
689
|
-
}
|
|
690
|
-
setExtraDeleteIssues([]);
|
|
691
|
-
setFormErrorSource("submit");
|
|
692
|
-
setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections, new Set(Object.keys(formDerivation?.derive ?? {})))
|
|
693
|
-
? result.error
|
|
694
|
-
: null);
|
|
695
|
-
}
|
|
696
|
-
// skip: on entity-success-but-extension-failure the extension-error
|
|
697
|
-
// banner is showing — don't notify (the caller navigates away on success
|
|
698
|
-
// and would unmount it before the user sees the failure).
|
|
699
|
-
if (shouldNotifyCaller(result, extensionsPersisted)) {
|
|
700
|
-
onSubmit?.(result, controller.getSnapshot().values);
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
finally {
|
|
704
|
-
isSubmittingRef.current = false;
|
|
705
|
-
setIsSubmitting(false);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
handleSubmitRef.current = handleSubmit;
|
|
709
|
-
wizardNextRef.current = isWizard ? handleWizardNext : () => { };
|
|
710
|
-
wizardBackRef.current = isWizard ? handleWizardBack : () => { };
|
|
711
|
-
// Screen forms (entityEdit/actionForm filling the shell height) drop the card;
|
|
712
|
-
// the title then lives in the shell header when the shell offers a slot.
|
|
713
|
-
const rootIssues = snapshot.errors[ROOT_ISSUE_PATH] ?? [];
|
|
714
|
-
const isScreenForm = fillScreenHeight === true && hideSectionTitles !== true && !insideDrawer;
|
|
715
|
-
const titleInShell = PageHeader !== undefined &&
|
|
716
|
-
pageHeaderSlotAvailable &&
|
|
717
|
-
hideSectionTitles !== true &&
|
|
718
|
-
headerRegion === undefined;
|
|
719
|
-
const menuPageHeaderActions = titleInShell && isScreenForm && ActionOverflowMenu !== undefined;
|
|
720
|
-
const cancelInHeaderMenu = menuPageHeaderActions && isWizard && onCancel !== undefined;
|
|
721
|
-
// Two groups — wizard navigation on the right/top, record actions on the
|
|
722
|
-
// left/below; destructive action sits outermost, farthest from the target
|
|
723
|
-
// action.
|
|
724
|
-
const hasSecondaryFormActions = (!menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined)) ||
|
|
725
|
-
(actions !== undefined && actions.length > 0) ||
|
|
726
|
-
(!menuPageHeaderActions && menuActions !== undefined && menuActions.length > 0) ||
|
|
727
|
-
(onCancel !== undefined && !cancelInHeaderMenu);
|
|
728
|
-
// Teil-C collapse rule: driven by the custom `actions` group only — a
|
|
729
|
-
// resolved true also folds the Copy-Link button (same visual group in the
|
|
730
|
-
// footer), but never Delete/Cancel, which keep their text regardless.
|
|
731
|
-
const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
|
|
732
|
-
const headerMenuItems = menuPageHeaderActions
|
|
733
|
-
? [
|
|
734
|
-
...(menuActions ?? []).map(menuActionToItem),
|
|
735
|
-
...(onCopyLink !== undefined
|
|
736
|
-
? [
|
|
737
|
-
{
|
|
738
|
-
id: "copy-link",
|
|
739
|
-
label: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink"),
|
|
740
|
-
icon: "link",
|
|
741
|
-
onSelect: () => {
|
|
742
|
-
void Promise.resolve(onCopyLink()).then(() => setLinkCopied(true));
|
|
743
|
-
},
|
|
744
|
-
},
|
|
745
|
-
]
|
|
746
|
-
: []),
|
|
747
|
-
...(cancelInHeaderMenu
|
|
748
|
-
? [
|
|
749
|
-
{
|
|
750
|
-
id: "cancel",
|
|
751
|
-
label: translate("kumiko.actions.cancel"),
|
|
752
|
-
icon: "x",
|
|
753
|
-
onSelect: () => onCancel(),
|
|
754
|
-
},
|
|
755
|
-
]
|
|
756
|
-
: []),
|
|
757
|
-
...(onDelete !== undefined
|
|
758
|
-
? [
|
|
759
|
-
{
|
|
760
|
-
id: "delete",
|
|
761
|
-
label: translate("kumiko.actions.delete"),
|
|
762
|
-
icon: "trash",
|
|
763
|
-
variant: "danger",
|
|
764
|
-
onSelect: () => setConfirmDeleteOpen(true),
|
|
765
|
-
},
|
|
766
|
-
]
|
|
767
|
-
: []),
|
|
768
|
-
]
|
|
769
|
-
: [];
|
|
770
|
-
const secondaryFormActions = (_jsxs(_Fragment, { children: [onDelete !== undefined && !menuPageHeaderActions && (_jsx(Button, { type: "button", variant: "danger-ghost", icon: "trash", testId: "render-edit-delete", disabled: disabled, onClick: () => setConfirmDeleteOpen(true), children: translate("kumiko.actions.delete") })), onCopyLink !== undefined && !menuPageHeaderActions && (_jsx(Button, { type: "button", variant: iconOnlyMidActions ? "secondary" : "link", icon: linkCopied ? "check" : "link", testId: "render-edit-copy-link", ...(iconOnlyMidActions && {
|
|
771
|
-
size: "icon",
|
|
772
|
-
ariaLabel: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink"),
|
|
773
|
-
}), onClick: async () => {
|
|
774
|
-
await onCopyLink();
|
|
775
|
-
setLinkCopied(true);
|
|
776
|
-
}, children: iconOnlyMidActions
|
|
777
|
-
? null
|
|
778
|
-
: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink") })), actions?.map((action) => (_jsx(RenderEditActionButton, { action: action, iconOnly: iconOnlyMidActions, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))), !menuPageHeaderActions &&
|
|
779
|
-
menuActions?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))), onCancel !== undefined && !cancelInHeaderMenu && (_jsx(Button, { type: "button", variant: "secondary", onClick: () => onCancel(), testId: "render-edit-cancel", children: translate("kumiko.actions.cancel") }))] }));
|
|
780
|
-
const headerSlot = screen.slots?.header;
|
|
781
|
-
const headerSlotMount = headerSlot !== undefined ? (_jsx(EditSlotMount, { slot: headerSlot, slotName: "header", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values })) : undefined;
|
|
782
|
-
// A function headerRegion places the slot itself (projectionDetail puts it
|
|
783
|
-
// into its header card next to the actions); otherwise the slot renders
|
|
784
|
-
// above the caller's own headerRegion.
|
|
785
|
-
const formHeaderRegion = typeof headerRegion === "function" ? (headerRegion(headerSlotMount)) : headerSlotMount !== undefined ? (_jsxs(_Fragment, { children: [headerSlotMount, headerRegion] })) : (headerRegion);
|
|
786
|
-
const titleActionSlot = screen.slots?.titleAction;
|
|
787
|
-
const titleActionMount = titleActionSlot !== undefined ? (_jsx(EditSlotMount, { slot: titleActionSlot, slotName: "titleAction", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values })) : undefined;
|
|
788
|
-
// A projectionDetail's synthesized entity is empty when every section is an
|
|
789
|
-
// extension or relatedList; that is not a fieldless submit form, so it must not
|
|
790
|
-
// draw a no-op Save. Extension registrations still get their Save, since
|
|
791
|
-
// persistExtensions() runs on the customSubmit path too.
|
|
792
|
-
const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
|
|
793
|
-
const showsSubmit = !viewMode &&
|
|
794
|
-
(isFormEditable ||
|
|
795
|
-
hasExtensionRegistrations ||
|
|
796
|
-
isFieldlessSubmitForm ||
|
|
797
|
-
submitWithoutChanges !== undefined);
|
|
798
|
-
const footerSlot = screen.slots?.footer;
|
|
45
|
+
draft.refs.saveDraftRef.current = draftActions.saveDraft;
|
|
46
|
+
const nav = createWizardNavigation(stepped, draftActions.saveDraft);
|
|
47
|
+
const base = { ...stepped, draftActions, nav };
|
|
48
|
+
const handleSubmit = createHandleSubmit(base);
|
|
49
|
+
draft.refs.handleSubmitRef.current = handleSubmit;
|
|
50
|
+
draft.refs.wizardNextRef.current = state.isWizard ? nav.handleWizardNext : () => { };
|
|
51
|
+
draft.refs.wizardBackRef.current = state.isWizard ? nav.handleWizardBack : () => { };
|
|
52
|
+
const engine = { ...base, handleSubmit };
|
|
53
|
+
const layout = deriveLayoutFlags(engine);
|
|
799
54
|
// An unregistered footer component renders null, so only a resolvable one may
|
|
800
55
|
// count as an action (else read-only screens get an empty footer strip).
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
writeFormFooterAction !== undefined;
|
|
814
|
-
const nextStepTitle = isWizard ? filteredSections[currentStep + 1]?.title : undefined;
|
|
815
|
-
const isCreate = (() => {
|
|
816
|
-
const id = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
817
|
-
return id == null || id === "";
|
|
818
|
-
})();
|
|
819
|
-
const changedFieldCount = Object.keys(snapshot.changes).length;
|
|
820
|
-
// Edit-mode screen forms always carry the dirty status (0 = quiet "No unsaved
|
|
821
|
-
// changes"); wizards keep the status-only-when-dirty behaviour.
|
|
822
|
-
const hasDirtyFooter = !isWizard &&
|
|
823
|
-
!viewMode &&
|
|
824
|
-
(dirtyFooter === true || (isScreenForm && !isCreate && headerRegion === undefined));
|
|
825
|
-
const unsavedCount = !hasDirtyFooter
|
|
826
|
-
? undefined
|
|
827
|
-
: changedFieldCount > 0
|
|
828
|
-
? changedFieldCount
|
|
829
|
-
: extensionDirty
|
|
830
|
-
? 1
|
|
831
|
-
: 0;
|
|
832
|
-
const isSettingsList = screen.layout.variant === "settings-list" && hideSectionTitles !== true;
|
|
833
|
-
const formActions = (_jsxs(_Fragment, { children: [showWizardBack && (_jsx(Button, { type: "button", variant: "secondary", icon: "chevron-left", onClick: handleWizardBack, testId: "render-edit-wizard-back", [FOOTER_ACTION_ROLE_PROP]: "back", children: translate("kumiko.actions.back") })), footerSlot !== undefined && (_jsx(EditSlotMount, { slot: footerSlot, slotName: "footer", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values, markSaved: markSaved, hasUnsavedChanges: hasUnsavedInput, wizardStep: isWizard ? { index: currentStep, isLast: isLastWizardStep } : undefined, ...(screen.slots?.footerPrimary === true && { [STICKY_PRIMARY_ACTION_PROP]: true }) })), isWizard && !isLastWizardStep && showsSubmit && !isCreateMode && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => void handleSubmit({ saveNow: true }), testId: "render-edit-wizard-save-close", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.wizard.save-and-close") })), showWizardNext && (_jsx(Button, { type: "submit", variant: "primary", iconEnd: "chevron-right", testId: "render-edit-wizard-next", [FOOTER_ACTION_ROLE_PROP]: "primary",
|
|
834
|
-
[NARROW_LABEL_PROP]: translate("kumiko.actions.next"), children: nextStepTitle !== undefined
|
|
835
|
-
? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
|
|
836
|
-
: translate("kumiko.actions.next") })), showSubmit &&
|
|
837
|
-
footerActions?.map((action) => (_jsx(Button, { type: "button", variant: action.variant ?? "secondary", ...(action.icon !== undefined && { icon: action.icon }), disabled: isSubmitting || disabled, onClick: () => {
|
|
838
|
-
controller.setValues(action.patch); // @cast-boundary form-values
|
|
839
|
-
void handleSubmit();
|
|
840
|
-
}, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), hasDirtyFooter && showsSubmit && unsavedCount !== undefined && unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => controller.reset(), testId: "render-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.form.discard") })), showSubmit && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged &&
|
|
841
|
-
!extensionDirty &&
|
|
842
|
-
!isFieldlessSubmitForm &&
|
|
843
|
-
!insideDrawer &&
|
|
844
|
-
submitWithoutChanges !== true) ||
|
|
845
|
-
isSubmitting ||
|
|
846
|
-
disabled ||
|
|
847
|
-
submitBlocked, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
|
|
848
|
-
(isWizard
|
|
849
|
-
? "kumiko.actions.finish"
|
|
850
|
-
: hasDirtyFooter
|
|
851
|
-
? "kumiko.form.saveChanges"
|
|
852
|
-
: "kumiko.actions.save")) })), writeFormFooterAction] }));
|
|
853
|
-
// Title + Subtitle, create/edit-aware. i18n-Keys (mode = "create"|"edit"):
|
|
854
|
-
// screen:<id>.<mode>.title / .<mode>.subtitle (mode = "create"|"edit"|"view")
|
|
855
|
-
// Fallback chain: mode-specific → generic (screen:<id>.title/.subtitle).
|
|
856
|
-
// title falls back to screenId; subtitle falls back to screen.description,
|
|
857
|
-
// itself run through translate() so an i18n key (the established
|
|
858
|
-
// convention for description, e.g. bundled-features) resolves instead of
|
|
859
|
-
// showing the raw key — translate() returns the input unchanged for plain
|
|
860
|
-
// prose, same as it does for an unknown key. Same slot
|
|
861
|
-
// section.description already fills for a section. Falls back to
|
|
862
|
-
// undefined (no subtitle) when neither is set.
|
|
863
|
-
const formMode = isCreate ? "create" : viewMode ? "view" : "edit";
|
|
864
|
-
const recordTitle = !isCreate && screen.recordTitleField !== undefined
|
|
865
|
-
? resolveRecordTitle(initial[screen.recordTitleField])
|
|
866
|
-
: undefined;
|
|
867
|
-
const resolveScreenText = (suffix) => {
|
|
868
|
-
for (const key of [
|
|
869
|
-
`screen:${i18nScreenId}.${formMode}.${suffix}`,
|
|
870
|
-
`screen:${i18nScreenId}.${suffix}`,
|
|
871
|
-
]) {
|
|
872
|
-
const value = translate(key);
|
|
873
|
-
if (value !== key)
|
|
874
|
-
return value;
|
|
875
|
-
}
|
|
876
|
-
return undefined;
|
|
56
|
+
const footerSlotComponent = useExtensionSectionComponent(footerSlotName(props.screen));
|
|
57
|
+
const footer = deriveFooterFlags(engine, layout, footerSlotComponent);
|
|
58
|
+
const titles = deriveTitles(engine, layout, footer);
|
|
59
|
+
const model = {
|
|
60
|
+
engine,
|
|
61
|
+
layout,
|
|
62
|
+
footer,
|
|
63
|
+
titles,
|
|
64
|
+
setFieldUntyped: form.controller.setField,
|
|
65
|
+
// @cast-boundary form-values: ExtensionSectionProps is not generic
|
|
66
|
+
// over TValues; controller is mount-lifetime-stable, see the onControlsReady effect in useRenderEditDraftSave.
|
|
67
|
+
patchForExtension: draft.patchAndScheduleDraftSave,
|
|
877
68
|
};
|
|
878
|
-
|
|
879
|
-
const formTitle = resolveTitleTemplate(screen.titleTemplate, snapshot.values, translate, plainFormTitle);
|
|
880
|
-
// In tabs mode (hideSectionTitles) screen.description is dropped — the head
|
|
881
|
-
// card carries it — but an explicit subtitle i18n override has no other
|
|
882
|
-
// source there, so it stays unless it merely resolves to the description.
|
|
883
|
-
const explicitSubtitle = resolveScreenText("subtitle");
|
|
884
|
-
const descriptionSubtitle = screen.description !== undefined ? translate(screen.description) : undefined;
|
|
885
|
-
const formSubtitle = hideSectionTitles !== true
|
|
886
|
-
? (explicitSubtitle ?? descriptionSubtitle)
|
|
887
|
-
: explicitSubtitle !== descriptionSubtitle
|
|
888
|
-
? explicitSubtitle
|
|
889
|
-
: undefined;
|
|
890
|
-
// Embedded settings-list forms (dashboard panels) have no shell header: the
|
|
891
|
-
// form title/subtitle move into the first section's header column instead of
|
|
892
|
-
// floating as a heading above the band.
|
|
893
|
-
const titleInSettingsHeader = isSettingsList && !titleInShell;
|
|
894
|
-
const usesFieldFlow = isScreenForm || insideDrawer;
|
|
895
|
-
// The caller's headerRegion already shows the record title in the shell page
|
|
896
|
-
// header, so the type title ("Release") above the form would repeat it.
|
|
897
|
-
const recordTitleInHeaderRegion = headerRegion !== undefined &&
|
|
898
|
-
recordTitle !== undefined &&
|
|
899
|
-
PageHeader !== undefined &&
|
|
900
|
-
pageHeaderSlotAvailable;
|
|
901
|
-
const allRequiredHint = (_jsx(Text, { variant: "muted", testId: "render-edit-all-required-hint", children: translate("kumiko.form.all-required") }));
|
|
902
|
-
const stepRail = isWizard && isScreenForm && StepBar !== undefined;
|
|
903
|
-
const upNextSection = screen.layout.wizard?.aside?.upNext === true ? filteredSections[currentStep + 1] : undefined;
|
|
904
|
-
const sectionNavItems = isScreenForm && !isStepped
|
|
905
|
-
? filteredSections.flatMap((section, index) => section.kind !== "extension" &&
|
|
906
|
-
section.kind !== "relatedList" &&
|
|
907
|
-
section.kind !== "writeForm" &&
|
|
908
|
-
section.visible &&
|
|
909
|
-
section.title !== undefined &&
|
|
910
|
-
section.title !== formTitle
|
|
911
|
-
? [{ id: formSectionDomId(index), title: section.title }]
|
|
912
|
-
: [])
|
|
913
|
-
: [];
|
|
914
|
-
const compactStepLabel = (() => {
|
|
915
|
-
const currentTitle = filteredSections[currentStep]?.title;
|
|
916
|
-
return currentTitle !== undefined
|
|
917
|
-
? translate("kumiko.wizard.step-with-title", {
|
|
918
|
-
current: currentStep + 1,
|
|
919
|
-
total: lastStepIndex + 1,
|
|
920
|
-
title: currentTitle,
|
|
921
|
-
})
|
|
922
|
-
: translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
|
|
923
|
-
})();
|
|
924
|
-
return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [menuActionConfirmDialog, titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
|
|
925
|
-
pageHeaderCompact && { overflowItems: headerMenuItems }), ...(headerMenuItems.length > 0 &&
|
|
926
|
-
!pageHeaderCompact &&
|
|
927
|
-
ActionOverflowMenu !== undefined && {
|
|
928
|
-
actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
|
|
929
|
-
}) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(focusFirstErrorKey > 0 && { focusFirstErrorKey }), ...(hideSectionTitles !== true &&
|
|
930
|
-
!titleInShell &&
|
|
931
|
-
!titleInSettingsHeader &&
|
|
932
|
-
!recordTitleInHeaderRegion && { title: formTitle }), ...(formSubtitle !== undefined &&
|
|
933
|
-
!stepRail &&
|
|
934
|
-
!titleInSettingsHeader && { subtitle: formSubtitle }), ...(isScreenForm && { screenForm: true }), ...(unsavedCount !== undefined &&
|
|
935
|
-
(unsavedCount > 0 || hasDirtyFooter) && { unsavedCount }), ...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems }), ...(stepRail && {
|
|
936
|
-
sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardStepSelect, ...updateModeStepBarProps, orientation: "vertical", heading: translate("kumiko.wizard.step", {
|
|
937
|
-
current: currentStep + 1,
|
|
938
|
-
total: lastStepIndex + 1,
|
|
939
|
-
}), ...(formSubtitle !== undefined && { description: formSubtitle }), subtitles: filteredSections.map((section) => section.kind === "fields" ? section.subtitle : undefined), ...(upNextSection !== undefined && {
|
|
940
|
-
upNext: {
|
|
941
|
-
heading: translate("kumiko.wizard.up-next"),
|
|
942
|
-
title: upNextSection.title ?? "",
|
|
943
|
-
...(upNextSection.kind === "fields" &&
|
|
944
|
-
upNextSection.subtitle !== undefined && { subtitle: upNextSection.subtitle }),
|
|
945
|
-
},
|
|
946
|
-
}), testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" })),
|
|
947
|
-
}), ...(hideActions !== true && hasFormActions && { actions: formActions }), ...(hideActions !== true &&
|
|
948
|
-
hasSecondaryFormActions && { secondaryActions: secondaryFormActions }), testId: "render-edit-form", stickyActions: isWizard || fillScreenHeight === true, ...(screen.layout.width !== undefined && { width: screen.layout.width }), ...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion }), ...(titleActionMount !== undefined && { titleAction: titleActionMount }), ...(fillHeight && { fillHeight }), ...(hideSectionTitles === true && { chromeless: true }), ...(summary !== undefined && { summary }), children: _jsxs(AllFieldsRequiredProvider, { value: showsAllRequiredHint, children: [leadContent, showsAllRequiredHint && !insideDrawer && allRequiredHint, draftCandidates !== null && (_jsx(Banner, { variant: "info", testId: "render-edit-draft-picker", actions: [
|
|
949
|
-
...draftCandidates.map((candidate) => (_jsx(Button, { type: "button", variant: "link", onClick: () => adoptDraft(candidate), testId: `render-edit-draft-pick-${candidate.id}`, children: formatWhen(candidate.savedAt) }, candidate.id))),
|
|
950
|
-
_jsx(Button, { type: "button", variant: "link", onClick: () => setDraftCandidates(null), testId: "render-edit-draft-start-new", children: translate("kumiko.form.draft.start-new") }, "start-new"),
|
|
951
|
-
], children: _jsx(Text, { children: translate(draftCandidates.length === 1
|
|
952
|
-
? "kumiko.form.draft.resume-single"
|
|
953
|
-
: "kumiko.form.draft.resume-multiple") }) })), isWizard && !stepRail && (_jsxs(_Fragment, { children: [Progress !== undefined && (_jsx(Progress, { value: (currentStep + 1) / (lastStepIndex + 1), testId: "render-edit-wizard-progress" })), StepBar === undefined ? (_jsx(Text, { variant: "small", testId: "render-edit-wizard-step-label", children: compactStepLabel })) : (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardStepSelect, ...updateModeStepBarProps, testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" }))] })), isTabs &&
|
|
954
|
-
isStepped &&
|
|
955
|
-
(() => {
|
|
956
|
-
// No Tabs primitive registered → every section renders stacked
|
|
957
|
-
// (stepHidden below is gated on the same condition), which is the
|
|
958
|
-
// single-layout behaviour. Degrading to one long form beats
|
|
959
|
-
// hiding sections behind a strip that never drew.
|
|
960
|
-
if (Tabs === undefined)
|
|
961
|
-
return null;
|
|
962
|
-
return (_jsx(Tabs, { testId: "render-edit-tabs", items: filteredSections.map((section, index) => ({
|
|
963
|
-
id: tabIdAt(section, index),
|
|
964
|
-
label: section.title ?? "",
|
|
965
|
-
})), activeId: tabIdAt(filteredSections[currentStep], currentStep), onSelect: (id) => {
|
|
966
|
-
const index = filteredSections.findIndex((s, i) => tabIdAt(s, i) === id);
|
|
967
|
-
// skip: the strip reported an id no section owns.
|
|
968
|
-
if (index === -1)
|
|
969
|
-
return;
|
|
970
|
-
jumpToStep(index);
|
|
971
|
-
} }));
|
|
972
|
-
})(), filteredSections.map((section, sectionIndex) => {
|
|
973
|
-
// turns this section's own `actions` (RowAction[]) into
|
|
974
|
-
// already-bound buttons via the caller-supplied builder, rendered
|
|
975
|
-
// through the same RenderEditActionButton the top-level `actions`
|
|
976
|
-
// prop uses (same style/confirm/icon/error handling).
|
|
977
|
-
const sectionActions = section.actions;
|
|
978
|
-
const sectionActionsEl = sectionActions !== undefined && sectionActions.length > 0
|
|
979
|
-
? buildSectionActions?.(sectionActions)?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id)))
|
|
980
|
-
: undefined;
|
|
981
|
-
// Wizard steps and tabs stay mounted while off-screen (native
|
|
982
|
-
// `hidden`, not unmounted) so an extension section's submit-registry
|
|
983
|
-
// entry (useExtensionFormSubmit → registry.remove on unmount)
|
|
984
|
-
// survives navigating past its step — otherwise Finish only ran the
|
|
985
|
-
// last mounted step's handler and silently dropped earlier steps'
|
|
986
|
-
// writes. For tabs this is also what makes ONE submit cover every
|
|
987
|
-
// tab, including tabs the user never opened.
|
|
988
|
-
const stepHidden = isStepped && sectionIndex !== currentStep;
|
|
989
|
-
const wrapWizardStep = (key, el) => {
|
|
990
|
-
if (!isStepped)
|
|
991
|
-
return el;
|
|
992
|
-
if (WizardStepGroup === undefined) {
|
|
993
|
-
throw new Error(`RenderEdit: ${screen.layout.mode} layout requires primitives.WizardStepGroup, but none is registered.`);
|
|
994
|
-
}
|
|
995
|
-
return (_jsx(WizardStepGroup, { hidden: stepHidden, ...(hideSectionTitles === true && { inset: true }), children: el }, key));
|
|
996
|
-
};
|
|
997
|
-
// Off-screen wizard steps stay mounted (see comment above) but must
|
|
998
|
-
// not participate in native constraint validation, or the Next
|
|
999
|
-
// button's `type="submit"` triggers the browser's full-form check
|
|
1000
|
-
// — including required fields on unvisited steps, which are not
|
|
1001
|
-
// focusable while hidden, so the wizard silently stops navigating
|
|
1002
|
-
// (only a console warning, no visible error). A plain `hidden`
|
|
1003
|
-
// attribute does NOT bar descendants from constraint validation on
|
|
1004
|
-
// web — see WizardStepGroupProps for what implementations must
|
|
1005
|
-
// guarantee.
|
|
1006
|
-
if (section.kind === "extension") {
|
|
1007
|
-
const mount = (_jsx(ExtensionSectionMount, { section: section, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), initialValues: extensionInitialValues, values: snapshot.values,
|
|
1008
|
-
// @cast-boundary form-values: ExtensionSectionProps is not generic
|
|
1009
|
-
// over TValues; controller is mount-lifetime-stable, see onControlsReady above.
|
|
1010
|
-
patch: patchAndScheduleDraftSave, validate: scopedValidate, markSaved: markSaved, hideTitle: hideSectionTitles, ...(isWizard &&
|
|
1011
|
-
!isCreateMode && {
|
|
1012
|
-
stepKey: wizardStepKey(section, sectionIndex),
|
|
1013
|
-
onReportStepComplete: reportExtensionStepComplete,
|
|
1014
|
-
}), ...(hideSectionTitles !== true &&
|
|
1015
|
-
sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title));
|
|
1016
|
-
// Tabs mode: an unframed padded panel (the tab panel is the
|
|
1017
|
-
// surface), so extension content neither sits flush at the shell
|
|
1018
|
-
// edge nor touches the tab strip. testId stays only on the inner
|
|
1019
|
-
// Section — giving the wrapper the same one would register two
|
|
1020
|
-
// elements under one id.
|
|
1021
|
-
const wrapped = hideSectionTitles !== true ? (mount) : (_jsx(Card, { options: { framed: false }, ...(sectionActionsEl !== undefined && {
|
|
1022
|
-
slots: { headerActions: sectionActionsEl },
|
|
1023
|
-
}), children: mount }, section.title));
|
|
1024
|
-
return wrapWizardStep(section.title, wrapped);
|
|
1025
|
-
}
|
|
1026
|
-
if (section.kind === "relatedList") {
|
|
1027
|
-
// parentId is the displayed record's id — without it there's no
|
|
1028
|
-
// parent row whose related rows could be queried.
|
|
1029
|
-
// Rejected at boot in wizard layouts, so no WizardStepGroup here.
|
|
1030
|
-
const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
1031
|
-
if (parentId === null)
|
|
1032
|
-
return null;
|
|
1033
|
-
const mount = (_jsx(RelatedListSection, { section: section, parentId: parentId,
|
|
1034
|
-
// @cast-boundary form-values: TValues is structurally a Record.
|
|
1035
|
-
record: snapshot.values, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, ...(refreshToken !== undefined && { refreshToken }), ...(fillScreenHeight === true && { grow: true }), ...(hideSectionTitles !== true &&
|
|
1036
|
-
sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(onRelatedListDrawerAction !== undefined && {
|
|
1037
|
-
onOpenDrawer: onRelatedListDrawerAction,
|
|
1038
|
-
}) }, section.title));
|
|
1039
|
-
// Tabs mode: the table runs full-bleed under the tab strip (board
|
|
1040
|
-
// layout); the toolbar buttons live in the list's own toolbar.
|
|
1041
|
-
return hideSectionTitles === true ? (_jsx("div", { className: fillScreenHeight === true ? "flex min-h-0 flex-1 flex-col" : undefined, children: mount }, section.title)) : (mount);
|
|
1042
|
-
}
|
|
1043
|
-
if (section.kind === "writeForm") {
|
|
1044
|
-
// Own submit button + dispatcher call, entirely independent of
|
|
1045
|
-
// this screen's (nonexistent, on projectionDetail) form submit —
|
|
1046
|
-
// rejected at boot in wizard layouts, so no WizardStepGroup here.
|
|
1047
|
-
return (_jsx(WriteFormSection, { section: section, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, onSubmitted: () => onReload?.(), ...(isLoneWriteFormTab && { onFooterAction: setWriteFormFooterAction }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title ?? `write-form-${sectionIndex}`));
|
|
1048
|
-
}
|
|
1049
|
-
if (!section.visible)
|
|
1050
|
-
return null;
|
|
1051
|
-
// Title-less sections would otherwise collide on key/testId, so use the index
|
|
1052
|
-
// as a fallback.
|
|
1053
|
-
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
1054
|
-
const renderFieldGrid = (fields, columns, gridKey, columnsDeclared) => (_jsx(Grid, { columns: usesFieldFlow && columnsDeclared !== true ? 1 : columns, ...(isSettingsList ? { list: true } : usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(!isSettingsList && usesFieldFlow && { flow: true }), ...(isSettingsList && {
|
|
1055
|
-
layout: "row",
|
|
1056
|
-
accent: fieldAccent?.(field.field) === true,
|
|
1057
|
-
}), issues: snapshot.errors[field.field], onChange: (v) => {
|
|
1058
|
-
controller.setField(field.field, v);
|
|
1059
|
-
clearErrorsOnInput(field.field);
|
|
1060
|
-
if (validateOnChange === true)
|
|
1061
|
-
controller.validate([field.field]);
|
|
1062
|
-
}, GridCell: GridCell, featureName: featureName, ...(labelAppendix !== undefined && {
|
|
1063
|
-
labelAppendix: labelAppendix(field.field),
|
|
1064
|
-
}), ...(fieldAppendix !== undefined && {
|
|
1065
|
-
fieldAppendix: fieldAppendix(field.field),
|
|
1066
|
-
}), ...(fieldAppendixPlacement !== undefined && {
|
|
1067
|
-
appendixPlacement: fieldAppendixPlacement,
|
|
1068
|
-
}), ...(fieldDescription !== undefined && {
|
|
1069
|
-
description: fieldDescription(field.field),
|
|
1070
|
-
}), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...(hideSectionTitles !== true &&
|
|
1071
|
-
section.title !== undefined && { sectionTitle: section.title }), ...((!isCreate || markChangedFields) &&
|
|
1072
|
-
field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
|
|
1073
|
-
// Tabs mode: the tab panel is the surface, so field sections render
|
|
1074
|
-
// as unframed padded panels; group cards inside stay framed.
|
|
1075
|
-
// Stays `Card`, not `Section` — Section always flattens to a
|
|
1076
|
-
// borderless divider when rendered inside this component's own
|
|
1077
|
-
// `<Form>` (InsideFormContext), even with `chromeless` set, so it
|
|
1078
|
-
// cannot stand in for the tabs-mode card here.
|
|
1079
|
-
if (hideSectionTitles === true) {
|
|
1080
|
-
if (section.groups !== undefined) {
|
|
1081
|
-
const groupsGrid = (_jsx(Grid, { columns: 1, children: section.groups.map((group, groupIndex) => (_jsx(Card, { slots: { title: group.title }, testId: `section-${sectionKey}-group-${groupIndex}`, children: renderFieldGrid(group.fields, group.columns, `${sectionKey}-group-${groupIndex}-grid`, group.columnsDeclared) }, group.title))) }, `${sectionKey}-groups`));
|
|
1082
|
-
// `actions` on a `groups` section has no group of its own to
|
|
1083
|
-
// sit in and would need an extra title-less Card around the
|
|
1084
|
-
// group cards (Card-in-Card) — the boot-validator rejects that
|
|
1085
|
-
// combination in tabs mode, so `sectionActionsEl` is always
|
|
1086
|
-
// undefined here at runtime.
|
|
1087
|
-
return wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, children: groupsGrid }, sectionKey));
|
|
1088
|
-
}
|
|
1089
|
-
// No section title here — the Tab strip right above
|
|
1090
|
-
// already names this panel, so a repeated Card title would just
|
|
1091
|
-
// duplicate it. The header row only exists to carry the section
|
|
1092
|
-
// description and actions, never the title text.
|
|
1093
|
-
const cardEl = (_jsx(Card, { options: { framed: false }, ...((sectionActionsEl !== undefined || section.description !== undefined) && {
|
|
1094
|
-
slots: {
|
|
1095
|
-
...(section.description !== undefined && { subtitle: section.description }),
|
|
1096
|
-
...(sectionActionsEl !== undefined && { headerActions: sectionActionsEl }),
|
|
1097
|
-
},
|
|
1098
|
-
}), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`, section.columnsDeclared) }, sectionKey));
|
|
1099
|
-
return wrapWizardStep(sectionKey, cardEl);
|
|
1100
|
-
}
|
|
1101
|
-
// Suppress the section header when it would just repeat the form
|
|
1102
|
-
// title verbatim (typical for single-section actionForms), or the
|
|
1103
|
-
// tab label right above it. Without a Tabs primitive the sections
|
|
1104
|
-
// render stacked, and then the titles are what tells them apart.
|
|
1105
|
-
const repeatsTabLabel = isTabs && isStepped;
|
|
1106
|
-
const headerFallback = titleInSettingsHeader && sectionIndex === 0;
|
|
1107
|
-
const sectionTitle = headerFallback
|
|
1108
|
-
? (section.title ?? formTitle)
|
|
1109
|
-
: section.title === formTitle || repeatsTabLabel
|
|
1110
|
-
? undefined
|
|
1111
|
-
: section.title;
|
|
1112
|
-
const sectionSubtitle = section.description ?? (headerFallback ? formSubtitle : undefined);
|
|
1113
|
-
const sectionEl = (_jsxs(Section, { ...(sectionNavItems.some((item) => item.id === formSectionDomId(sectionIndex)) && {
|
|
1114
|
-
id: formSectionDomId(sectionIndex),
|
|
1115
|
-
}), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(sectionSubtitle !== undefined && { subtitle: sectionSubtitle }), ...(section.icon !== undefined && { icon: section.icon }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(isSettingsList && { layout: "settings-list" }), testId: `section-${sectionKey}`, children: [showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint, renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`, section.columnsDeclared)] }, sectionKey));
|
|
1116
|
-
return wrapWizardStep(sectionKey, sectionEl);
|
|
1117
|
-
}), formError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
|
|
1118
|
-
onReload();
|
|
1119
|
-
setFormError(null);
|
|
1120
|
-
setExtraDeleteIssues([]);
|
|
1121
|
-
}, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), rootIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-root", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`))), extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })), formError === null &&
|
|
1122
|
-
rootIssues.map((issue) => (_jsx(Banner, { variant: "error", testId: "render-edit-root-issue", children: _jsx(Text, { children: translate(issue.i18nKey, issue.params) }) }, issue.i18nKey))), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
|
|
1123
|
-
const rejection = await onDelete();
|
|
1124
|
-
// A rejected delete (e.g. a preDelete hook) carries its reason in
|
|
1125
|
-
// the field issues; the top-level key is only "validation failed".
|
|
1126
|
-
if (isDispatcherRejection(rejection)) {
|
|
1127
|
-
const [issue, ...otherIssues] = rejection.details?.fields ?? [];
|
|
1128
|
-
setExtraDeleteIssues(otherIssues);
|
|
1129
|
-
setFormErrorSource("delete");
|
|
1130
|
-
setFormError(issue === undefined
|
|
1131
|
-
? rejection
|
|
1132
|
-
: { ...rejection, i18nKey: issue.i18nKey, i18nParams: issue.params });
|
|
1133
|
-
}
|
|
1134
|
-
}, testId: "render-edit-delete-dialog" }))] }) })] }));
|
|
69
|
+
return renderEditLayout(model);
|
|
1135
70
|
}
|