@cosmicdrift/kumiko-renderer 0.389.0 → 0.390.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/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 +10 -10
- package/src/changes.json +54 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { buildControlsFor, deriveChangeState, flushPendingDraftSave, } from "./render-edit-draft-save-helpers.js";
|
|
3
|
+
import { PATCH_DRAFT_SAVE_DEBOUNCE_MS } from "./use-wizard-form-draft.js";
|
|
4
|
+
export function useRenderEditDraftSave(props, state, form) {
|
|
5
|
+
const { controller, snapshot } = form;
|
|
6
|
+
const { disabled, schema } = props;
|
|
7
|
+
const { isSubmitting, draftEnabled, dispatcher } = state;
|
|
8
|
+
// Controlled mode. Both callbacks live in refs so a
|
|
9
|
+
// re-rendering caller (identity-unstable `onChange`/`onControlsReady`
|
|
10
|
+
// closures, e.g. inline arrows) doesn't retrigger these effects — only a
|
|
11
|
+
// real snapshot mutation (typing, patch()) does. Without this, a caller
|
|
12
|
+
// whose onChange calls patch() to fill other fields (the VIN-decode use
|
|
13
|
+
// case) would loop: patch → new snapshot → effect
|
|
14
|
+
// re-subscribes with a "new" onChange → refires.
|
|
15
|
+
const onChangeRef = useRef(props.onChange);
|
|
16
|
+
onChangeRef.current = props.onChange;
|
|
17
|
+
// Guards against redelivering to the SAME callback identity on every
|
|
18
|
+
// render (an inline-arrow onControlsReady would otherwise refire the
|
|
19
|
+
// effect below on every render since the callback itself is now a dep).
|
|
20
|
+
// Tracks the actual prop, not a ref snapshot, so a caller that swaps in a
|
|
21
|
+
// real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
|
|
22
|
+
// gets delivered to for THIS mount instead of never.
|
|
23
|
+
const deliveredControlsToRef = useRef(undefined);
|
|
24
|
+
const onControlsReadyRef = useRef(props.onControlsReady);
|
|
25
|
+
onControlsReadyRef.current = props.onControlsReady;
|
|
26
|
+
const hasNavigatedRef = useRef(false);
|
|
27
|
+
const submittedRef = useRef(false);
|
|
28
|
+
const isSubmittingRef = useRef(false);
|
|
29
|
+
const scopeFieldNamesRef = useRef(form.scopeFieldNames);
|
|
30
|
+
scopeFieldNamesRef.current = form.scopeFieldNames;
|
|
31
|
+
const scopedValidate = useCallback(() => controller.validate(scopeFieldNamesRef.current), [controller]);
|
|
32
|
+
// The extension dirty flag aggregates every section; each section clears its
|
|
33
|
+
// own part, so a host-side markSaved must not reset it.
|
|
34
|
+
const markSaved = useCallback((fields) => {
|
|
35
|
+
if (fields === undefined)
|
|
36
|
+
controller.rebase();
|
|
37
|
+
else
|
|
38
|
+
controller.rebaseFields(fields);
|
|
39
|
+
}, [controller]);
|
|
40
|
+
// `saveDraft` (defined below) is a fresh closure every render, over that
|
|
41
|
+
// render's `draftKey`/`draftId` — the ref lets the debounce timer below
|
|
42
|
+
// always call the CURRENT one even though the timer was scheduled by an
|
|
43
|
+
// earlier render's patch() call. `currentStepRef` gives it the current
|
|
44
|
+
// wizard step without depending on `currentStep` (computed further down,
|
|
45
|
+
// from `filteredSections`) at the point patchAndScheduleDraftSave itself
|
|
46
|
+
// is defined — kept fresh where `currentStep` is computed below.
|
|
47
|
+
const saveDraftRef = useRef(() => { });
|
|
48
|
+
// `handleSubmit` (defined below) is a fresh closure over this render's
|
|
49
|
+
// snapshot/extensionDirty, while the onControlsReady effect fires once per
|
|
50
|
+
// mount — the ref lets controls.submit() always reach the current one.
|
|
51
|
+
const handleSubmitRef = useRef(async () => { });
|
|
52
|
+
const wizardNextRef = useRef(() => { });
|
|
53
|
+
const wizardBackRef = useRef(() => { });
|
|
54
|
+
const currentStepRef = useRef(0);
|
|
55
|
+
const draftSaveTimerRef = useRef(null);
|
|
56
|
+
const refs = {
|
|
57
|
+
onChangeRef,
|
|
58
|
+
deliveredControlsToRef,
|
|
59
|
+
onControlsReadyRef,
|
|
60
|
+
hasNavigatedRef,
|
|
61
|
+
submittedRef,
|
|
62
|
+
isSubmittingRef,
|
|
63
|
+
scopeFieldNamesRef,
|
|
64
|
+
saveDraftRef,
|
|
65
|
+
handleSubmitRef,
|
|
66
|
+
wizardNextRef,
|
|
67
|
+
wizardBackRef,
|
|
68
|
+
currentStepRef,
|
|
69
|
+
draftSaveTimerRef,
|
|
70
|
+
};
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
return () => flushPendingDraftSave(draftSaveTimerRef, saveDraftRef, currentStepRef);
|
|
73
|
+
}, []);
|
|
74
|
+
// patch() (controlled mode, extension sections) applies immediately —
|
|
75
|
+
// only the resulting draft save is debounced, so a patch() burst
|
|
76
|
+
// (VIN-decode filling several fields, or onChange fanning a keystroke out
|
|
77
|
+
// through patch(), see the VIN-decode test above) collapses into one save.
|
|
78
|
+
const patchAndScheduleDraftSave = useCallback((partial) => {
|
|
79
|
+
controller.setValues(partial);
|
|
80
|
+
// skip: no draft screen, locked, or already submitted
|
|
81
|
+
if (!draftEnabled || dispatcher === undefined || disabled || submittedRef.current)
|
|
82
|
+
return;
|
|
83
|
+
if (draftSaveTimerRef.current !== null)
|
|
84
|
+
clearTimeout(draftSaveTimerRef.current);
|
|
85
|
+
draftSaveTimerRef.current = setTimeout(() => {
|
|
86
|
+
draftSaveTimerRef.current = null;
|
|
87
|
+
// skip: locked or submitted since the timer was set
|
|
88
|
+
if (disabled || submittedRef.current)
|
|
89
|
+
return;
|
|
90
|
+
saveDraftRef.current(currentStepRef.current);
|
|
91
|
+
}, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
|
|
92
|
+
}, [controller, draftEnabled, dispatcher, disabled]);
|
|
93
|
+
// Runs before the onChange effect below (declaration order = React
|
|
94
|
+
// execution order) so that an onChange fired on mount always sees
|
|
95
|
+
// `controls !== undefined` — both effects depend only on mount-stable
|
|
96
|
+
// values (controller/scopedValidate/patchAndScheduleDraftSave), so the
|
|
97
|
+
// swap is otherwise a no-op. See the VIN-decode test: an initial
|
|
98
|
+
// value that should derive dependent fields on mount needs controls.patch
|
|
99
|
+
// available on the very first onChange call, not just from the second
|
|
100
|
+
// keystroke onward.
|
|
101
|
+
const hasControlsReady = props.onControlsReady !== undefined;
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
// skip: caller registered no onControlsReady
|
|
104
|
+
if (!hasControlsReady)
|
|
105
|
+
return;
|
|
106
|
+
const ready = onControlsReadyRef.current;
|
|
107
|
+
// skip: callback vanished before this effect ran
|
|
108
|
+
if (ready === undefined)
|
|
109
|
+
return;
|
|
110
|
+
// skip: this callback already received the controls
|
|
111
|
+
if (deliveredControlsToRef.current === ready)
|
|
112
|
+
return;
|
|
113
|
+
deliveredControlsToRef.current = ready;
|
|
114
|
+
ready(buildControlsFor({
|
|
115
|
+
controller,
|
|
116
|
+
patch: patchAndScheduleDraftSave,
|
|
117
|
+
validate: scopedValidate,
|
|
118
|
+
handleSubmitRef,
|
|
119
|
+
wizardNextRef,
|
|
120
|
+
wizardBackRef,
|
|
121
|
+
}));
|
|
122
|
+
// Dep on boolean presence only — inline-arrow identity must not redeliver.
|
|
123
|
+
}, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
|
|
124
|
+
// `schema` is typically an inline caller prop (`schema={z.object({...})}`)
|
|
125
|
+
// with unstable identity across renders — as an effect dep it would refire
|
|
126
|
+
// on every parent render, not just on a snapshot change, risking a loop if
|
|
127
|
+
// the caller's onChange triggers a parent re-render. Held in a ref like
|
|
128
|
+
// onChangeRef so only a real snapshot mutation retriggers this effect.
|
|
129
|
+
const schemaRef = useRef(schema);
|
|
130
|
+
schemaRef.current = schema;
|
|
131
|
+
// Controls are guaranteed ready by the time this fires (see the
|
|
132
|
+
// onControlsReady effect above, which is declared first on purpose).
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const cb = onChangeRef.current;
|
|
135
|
+
// skip: caller registered no onChange
|
|
136
|
+
if (cb === undefined)
|
|
137
|
+
return;
|
|
138
|
+
cb(deriveChangeState(snapshot, schemaRef.current, scopeFieldNamesRef.current, isSubmitting));
|
|
139
|
+
}, [snapshot, isSubmitting]);
|
|
140
|
+
return { refs, scopedValidate, markSaved, patchAndScheduleDraftSave };
|
|
141
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FormValues } from "../headless/index.ts";
|
|
2
|
+
import { type UseFormResult } from "../hooks/use-form.ts";
|
|
3
|
+
import type { ResolvedRenderEditProps } from "./render-edit-render-model.ts";
|
|
4
|
+
import type { RenderEditState } from "./use-render-edit-state.ts";
|
|
5
|
+
export type RenderEditForm<TValues extends FormValues> = UseFormResult<TValues> & {
|
|
6
|
+
readonly scopeFieldNames: readonly string[] | undefined;
|
|
7
|
+
readonly writeVerb: "create" | "update";
|
|
8
|
+
readonly clearErrorsOnInput: (changedField: string) => void;
|
|
9
|
+
};
|
|
10
|
+
export declare function useRenderEditForm<TValues extends FormValues, TCtx>(props: ResolvedRenderEditProps<TValues, TCtx>, state: RenderEditState): RenderEditForm<TValues>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { layoutEditFields } from "../app/layout-fields.js";
|
|
3
|
+
import { useForm } from "../hooks/use-form.js";
|
|
4
|
+
import { changedDerivedValues } from "./form-derivation-patch.js";
|
|
5
|
+
import { deriveFormFields } from "./render-edit-helpers.js";
|
|
6
|
+
import { issuesAfterEdit } from "./render-edit-logic.js";
|
|
7
|
+
// Must be computed before submitConfig/useForm bakes it in (the controller
|
|
8
|
+
// freezes it on first render). This is the unfiltered field-name set from
|
|
9
|
+
// `fieldsFilter` ∩ `fields` — not the value-dependent `filteredSections`
|
|
10
|
+
// (which also drops `section.visible === false`). Hidden fields still fall
|
|
11
|
+
// out via form-controller's `hiddenFields` path. undefined = unscoped, since
|
|
12
|
+
// scoping would silently drop root-level .refine() issues.
|
|
13
|
+
// A `fieldsFilter` that matches nothing in `fields` (typo, renamed field)
|
|
14
|
+
// must fall back to unscoped too — an empty scope array would filter out
|
|
15
|
+
// ALL validation issues, letting submit() through
|
|
16
|
+
// unvalidated on a form that also renders no sections.
|
|
17
|
+
function useScopeFieldNames(fieldsFilter, fields) {
|
|
18
|
+
return useMemo(() => {
|
|
19
|
+
if (fieldsFilter === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
const matched = fieldsFilter.filter((f) => Object.hasOwn(fields, f));
|
|
22
|
+
return matched.length === 0 ? undefined : matched;
|
|
23
|
+
}, [fieldsFilter, fields]);
|
|
24
|
+
}
|
|
25
|
+
// A write-excluded field must never reach the payload: the handler schema is
|
|
26
|
+
// z.never() for it, so even an unchanged read-only value would be rejected.
|
|
27
|
+
function useOmitFields(screen, writeVerb) {
|
|
28
|
+
return useMemo(() => [
|
|
29
|
+
...layoutEditFields(screen)
|
|
30
|
+
.filter((spec) => spec.submit === false)
|
|
31
|
+
.map((spec) => spec.field),
|
|
32
|
+
...(screen.writeExcludedFields?.[writeVerb] ?? []),
|
|
33
|
+
], [screen, writeVerb]);
|
|
34
|
+
}
|
|
35
|
+
function buildSubmitConfig(props, omitFields, scopeFieldNames) {
|
|
36
|
+
const { writeCommand, payloadMode, buildPayload, serverFieldPathPrefix } = props;
|
|
37
|
+
return writeCommand !== undefined
|
|
38
|
+
? {
|
|
39
|
+
type: writeCommand,
|
|
40
|
+
payloadMode,
|
|
41
|
+
...(buildPayload !== undefined && { buildPayload }),
|
|
42
|
+
...(omitFields.length > 0 && { omitFields }),
|
|
43
|
+
...(serverFieldPathPrefix !== undefined && { serverFieldPathPrefix }),
|
|
44
|
+
...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
|
|
45
|
+
}
|
|
46
|
+
: undefined;
|
|
47
|
+
}
|
|
48
|
+
export function useRenderEditForm(props, state) {
|
|
49
|
+
const { screen, initial, schema, ctx, formDerivation, refreshToken } = props;
|
|
50
|
+
const fields = useMemo(() => deriveFormFields(screen), [screen]);
|
|
51
|
+
const scopeFieldNames = useScopeFieldNames(props.fields, fields);
|
|
52
|
+
// Configure submission only when the caller provides a writeCommand; with
|
|
53
|
+
// customSubmit, the form controller has no submit wiring because
|
|
54
|
+
// controller.submit() is not called.
|
|
55
|
+
const writeVerb = state.isCreateMode ? "create" : "update";
|
|
56
|
+
const omitFields = useOmitFields(screen, writeVerb);
|
|
57
|
+
const submitConfig = buildSubmitConfig(props, omitFields, scopeFieldNames);
|
|
58
|
+
const { controller, snapshot } = useForm({
|
|
59
|
+
initial,
|
|
60
|
+
fields,
|
|
61
|
+
...(schema !== undefined && { schema }),
|
|
62
|
+
...(ctx !== undefined && { ctx }),
|
|
63
|
+
...(submitConfig !== undefined && { submit: submitConfig }),
|
|
64
|
+
});
|
|
65
|
+
// Derived fields are written into form state (not only displayed) so required
|
|
66
|
+
// validation and the submit payload carry them; only a real change is set, which
|
|
67
|
+
// keeps this from looping.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
// skip: screen has no derived fields
|
|
70
|
+
if (formDerivation === undefined)
|
|
71
|
+
return;
|
|
72
|
+
const patch = changedDerivedValues(snapshot.values, formDerivation);
|
|
73
|
+
if (patch !== undefined)
|
|
74
|
+
controller.setValues(patch); // @cast-boundary form-values
|
|
75
|
+
}, [snapshot.values, formDerivation, controller]);
|
|
76
|
+
// Wired at the user-input boundary, not in controller.setField: computed
|
|
77
|
+
// fields, prefill, draft restore and action patches also call setValues
|
|
78
|
+
// after a failed submit and must not wipe the errors.
|
|
79
|
+
const clearErrorsOnInput = (changedField) => {
|
|
80
|
+
const errors = controller.getSnapshot().errors;
|
|
81
|
+
const remaining = issuesAfterEdit(errors, changedField);
|
|
82
|
+
if (remaining !== errors)
|
|
83
|
+
controller.setErrors(remaining);
|
|
84
|
+
if (state.formErrorSource === "submit" && state.formError?.code !== "version_conflict") {
|
|
85
|
+
state.setFormError(null);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const lastRefreshTokenRef = useRef(refreshToken);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
// skip: refreshToken unchanged
|
|
91
|
+
if (lastRefreshTokenRef.current === refreshToken)
|
|
92
|
+
return;
|
|
93
|
+
lastRefreshTokenRef.current = refreshToken;
|
|
94
|
+
// skip: never overwrite user input, a dirty form keeps its values.
|
|
95
|
+
if (controller.getSnapshot().isDirty)
|
|
96
|
+
return;
|
|
97
|
+
controller.setValues(initial);
|
|
98
|
+
controller.rebase();
|
|
99
|
+
}, [refreshToken, controller, initial]);
|
|
100
|
+
return { controller, snapshot, scopeFieldNames, writeVerb, clearErrorsOnInput };
|
|
101
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Dispatch, type ReactNode, type SetStateAction } from "react";
|
|
2
|
+
import type { EditSectionViewModel, EditViewModel, FormValues } from "../headless/index.ts";
|
|
3
|
+
import type { RenderEditCore } from "./render-edit-render-model.ts";
|
|
4
|
+
export type RenderEditSections = {
|
|
5
|
+
readonly vm: EditViewModel;
|
|
6
|
+
readonly filteredSections: readonly EditSectionViewModel[];
|
|
7
|
+
readonly showsAllRequiredHint: boolean;
|
|
8
|
+
readonly isFormEditable: boolean;
|
|
9
|
+
readonly isFieldless: boolean;
|
|
10
|
+
readonly fillHeight: boolean;
|
|
11
|
+
readonly isLoneWriteFormTab: boolean;
|
|
12
|
+
readonly writeFormFooterAction: ReactNode;
|
|
13
|
+
readonly setWriteFormFooterAction: Dispatch<SetStateAction<ReactNode>>;
|
|
14
|
+
};
|
|
15
|
+
export declare function useRenderEditSections<TValues extends FormValues, TCtx>(core: RenderEditCore<TValues, TCtx>): RenderEditSections;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { computeEditViewModel } from "../headless/index.js";
|
|
3
|
+
import { filterEditSections, hasEditableSection } from "./render-edit-logic.js";
|
|
4
|
+
function useEditViewModel({ props, state, form, }) {
|
|
5
|
+
const { screen, entity, featureName, formDerivation, actionFormScreenQn } = props;
|
|
6
|
+
const entityIdProp = props.entityId;
|
|
7
|
+
const { translate, isCreateMode } = state;
|
|
8
|
+
const { writeVerb } = form;
|
|
9
|
+
const values = form.snapshot.values;
|
|
10
|
+
return useMemo(() => computeEditViewModel({
|
|
11
|
+
screen,
|
|
12
|
+
entity,
|
|
13
|
+
values,
|
|
14
|
+
translate,
|
|
15
|
+
featureName,
|
|
16
|
+
mode: writeVerb,
|
|
17
|
+
...(!isCreateMode && entityIdProp != null && { entityId: entityIdProp }),
|
|
18
|
+
...(formDerivation !== undefined && { derivation: formDerivation }),
|
|
19
|
+
...(actionFormScreenQn !== undefined && { actionFormScreenQn }),
|
|
20
|
+
}), [
|
|
21
|
+
formDerivation,
|
|
22
|
+
actionFormScreenQn,
|
|
23
|
+
screen,
|
|
24
|
+
entity,
|
|
25
|
+
values,
|
|
26
|
+
translate,
|
|
27
|
+
featureName,
|
|
28
|
+
writeVerb,
|
|
29
|
+
isCreateMode,
|
|
30
|
+
entityIdProp,
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
function useFilteredSections(vm, fieldsFilter) {
|
|
34
|
+
return useMemo(
|
|
35
|
+
// A fully-hidden "fields" section (every field in it currently
|
|
36
|
+
// condition-hidden) must not occupy a wizard step; it would render
|
|
37
|
+
// empty and block Back/Next on nothing. Extension, relatedList and
|
|
38
|
+
// writeForm sections carry no `visible` (they own their own lifecycle /
|
|
39
|
+
// run their own query / own submit), so they always pass through. A
|
|
40
|
+
// section with no fields at all (e.g. a review-only step) has
|
|
41
|
+
// `visible: fields.some(...)` = false vacuously; that's "no fields to
|
|
42
|
+
// hide", not "hidden", so it stays too. Unless the create
|
|
43
|
+
// exclusion emptied it: that would leave an empty wizard step / tab.
|
|
44
|
+
() => filterEditSections(vm.sections, fieldsFilter).filter((section) => section.kind === "extension" ||
|
|
45
|
+
section.kind === "relatedList" ||
|
|
46
|
+
section.kind === "writeForm" ||
|
|
47
|
+
(section.fields.length === 0 && section.emptiedByWriteExclusion !== true) ||
|
|
48
|
+
section.visible), [vm.sections, fieldsFilter]);
|
|
49
|
+
}
|
|
50
|
+
function useAllFieldsRequired(filteredSections) {
|
|
51
|
+
return useMemo(() => {
|
|
52
|
+
const fillable = filteredSections.flatMap((section) => section.kind === "fields"
|
|
53
|
+
? section.fields.filter((field) => field.visible && !field.readOnly && field.type !== "boolean")
|
|
54
|
+
: []);
|
|
55
|
+
return fillable.length > 0 && fillable.every((field) => field.required);
|
|
56
|
+
}, [filteredSections]);
|
|
57
|
+
}
|
|
58
|
+
export function useRenderEditSections(core) {
|
|
59
|
+
const { props, state } = core;
|
|
60
|
+
const { hideSectionTitles, fillScreenHeight } = props;
|
|
61
|
+
const vm = useEditViewModel(core);
|
|
62
|
+
const filteredSections = useFilteredSections(vm, props.fields);
|
|
63
|
+
const allFieldsRequired = useAllFieldsRequired(filteredSections);
|
|
64
|
+
const showsAllRequiredHint = allFieldsRequired && hideSectionTitles !== true && !state.isWizard;
|
|
65
|
+
// true when editable fields exist or an extension opted into composed submit.
|
|
66
|
+
const isFormEditable = hasEditableSection(filteredSections);
|
|
67
|
+
// A fieldless form (input-less secretMint mint — the secret is
|
|
68
|
+
// server-generated, the mint step is only its submit button) has no
|
|
69
|
+
// section to gate on: it IS the action, so it always shows the submit
|
|
70
|
+
// button, and it can never go dirty either.
|
|
71
|
+
const isFieldless = Object.keys(props.entity.fields).length === 0;
|
|
72
|
+
// A lone relatedList tab (`hideSectionTitles` is only ever set by
|
|
73
|
+
// ProjectionDetailBody, which hands this component ONE section — its
|
|
74
|
+
// synthesized screen drops layout.mode, so an entityEdit tabs layout never
|
|
75
|
+
// reaches this line and `filteredSections[0]` stays the active section)
|
|
76
|
+
// needs its own tab panel capped at the available height so its table
|
|
77
|
+
// scrolls inside the panel instead of the whole page stretching to the
|
|
78
|
+
// row count — a short table still sizes to its content instead
|
|
79
|
+
// of stretching the panel to the bottom. Any other layout —
|
|
80
|
+
// multiple sections, a non-relatedList tab, stacked (non-tabs) forms —
|
|
81
|
+
// keeps normal document-flow height untouched.
|
|
82
|
+
const fillHeight = fillScreenHeight === true ||
|
|
83
|
+
(hideSectionTitles === true && filteredSections[0]?.kind === "relatedList");
|
|
84
|
+
// A lone writeForm tab is the whole page, so its submit joins this form's
|
|
85
|
+
// footer (pinned to the window edge like every other screen's save)
|
|
86
|
+
// instead of sitting in the section's title row.
|
|
87
|
+
const isLoneWriteFormTab = hideSectionTitles === true && filteredSections[0]?.kind === "writeForm";
|
|
88
|
+
const [writeFormFooterAction, setWriteFormFooterAction] = useState(undefined);
|
|
89
|
+
return {
|
|
90
|
+
vm,
|
|
91
|
+
filteredSections,
|
|
92
|
+
showsAllRequiredHint,
|
|
93
|
+
isFormEditable,
|
|
94
|
+
isFieldless,
|
|
95
|
+
fillHeight,
|
|
96
|
+
isLoneWriteFormTab,
|
|
97
|
+
writeFormFooterAction,
|
|
98
|
+
setWriteFormFooterAction,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { ErrorNotice } from "../app/error-reference.tsx";
|
|
3
|
+
import type { DispatcherError, FieldIssue, Translate } from "../headless/index.ts";
|
|
4
|
+
import type { FormErrorSource } from "./render-edit-helpers.tsx";
|
|
5
|
+
export declare function useRenderEditState({ screen, entityId, translate: translateProp, }: {
|
|
6
|
+
readonly screen: EntityEditScreenDefinition;
|
|
7
|
+
readonly entityId: string | null | undefined;
|
|
8
|
+
readonly translate: Translate | undefined;
|
|
9
|
+
}): {
|
|
10
|
+
confirmDeleteOpen: boolean;
|
|
11
|
+
setConfirmDeleteOpen: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
12
|
+
linkCopied: boolean;
|
|
13
|
+
setLinkCopied: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
14
|
+
isSubmitting: boolean;
|
|
15
|
+
setIsSubmitting: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
16
|
+
focusFirstErrorKey: number;
|
|
17
|
+
setFocusFirstErrorKey: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
18
|
+
formError: DispatcherError | null;
|
|
19
|
+
setFormError: import("react").Dispatch<import("react").SetStateAction<DispatcherError | null>>;
|
|
20
|
+
formErrorSource: FormErrorSource;
|
|
21
|
+
setFormErrorSource: import("react").Dispatch<import("react").SetStateAction<FormErrorSource>>;
|
|
22
|
+
extraDeleteIssues: readonly FieldIssue[];
|
|
23
|
+
setExtraDeleteIssues: import("react").Dispatch<import("react").SetStateAction<readonly FieldIssue[]>>;
|
|
24
|
+
actionError: ErrorNotice | null;
|
|
25
|
+
setActionError: import("react").Dispatch<import("react").SetStateAction<ErrorNotice | null>>;
|
|
26
|
+
rawStep: number;
|
|
27
|
+
setRawStep: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
28
|
+
passedStepKeys: ReadonlySet<string>;
|
|
29
|
+
setPassedStepKeys: import("react").Dispatch<import("react").SetStateAction<ReadonlySet<string>>>;
|
|
30
|
+
extensionStepComplete: Readonly<Record<string, boolean>>;
|
|
31
|
+
reportExtensionStepComplete: (stepKey: string, complete: boolean) => void;
|
|
32
|
+
activeTabId: string | undefined;
|
|
33
|
+
setActiveTabId: import("react").Dispatch<import("react").SetStateAction<string | undefined>>;
|
|
34
|
+
extensionDirty: boolean;
|
|
35
|
+
extensionDirtyRef: import("react").RefObject<boolean>;
|
|
36
|
+
hasExtensionRegistrations: boolean;
|
|
37
|
+
extensionErrorKey: string | null;
|
|
38
|
+
setExtensionErrorKey: import("react").Dispatch<import("react").SetStateAction<string | null>>;
|
|
39
|
+
extensionFormRegistry: import("../app/extension-form-submit.tsx").ExtensionFormRegistry;
|
|
40
|
+
runExtensionSubmits: (ctx: import("../app/extension-form-submit.tsx").ExtensionSubmitContext) => Promise<readonly import("../app/extension-form-submit.tsx").ExtensionSubmitResult[]>;
|
|
41
|
+
translate: Translate;
|
|
42
|
+
dispatcher: import("../headless/index.ts").Dispatcher | undefined;
|
|
43
|
+
isWizard: boolean;
|
|
44
|
+
isTabs: boolean;
|
|
45
|
+
draftEnabled: boolean;
|
|
46
|
+
isCreateMode: boolean;
|
|
47
|
+
draftStorage: import("../context/draft-storage-context.tsx").DraftStorage;
|
|
48
|
+
draftState: {
|
|
49
|
+
draftId: string | null;
|
|
50
|
+
setDraftId: import("react").Dispatch<import("react").SetStateAction<string | null>>;
|
|
51
|
+
draftCandidates: readonly import("./use-wizard-form-draft.ts").DraftCandidate[] | null;
|
|
52
|
+
setDraftCandidates: import("react").Dispatch<import("react").SetStateAction<readonly import("./use-wizard-form-draft.ts").DraftCandidate[] | null>>;
|
|
53
|
+
mintedDraftIdRef: import("react").RefObject<string | null>;
|
|
54
|
+
didListRef: import("react").RefObject<boolean>;
|
|
55
|
+
draftKey: string | undefined;
|
|
56
|
+
};
|
|
57
|
+
primitives: import("../primitives.tsx").PrimitivesRegistry;
|
|
58
|
+
menuActionToItem: (action: import("./render-edit-types.ts").RenderEditAction) => import("../primitives.tsx").ActionMenuItemSpec;
|
|
59
|
+
menuActionConfirmDialog: import("react").ReactNode;
|
|
60
|
+
pageHeaderSlotAvailable: boolean;
|
|
61
|
+
pageHeaderCompact: boolean;
|
|
62
|
+
insideDrawer: boolean;
|
|
63
|
+
isStepped: boolean;
|
|
64
|
+
};
|
|
65
|
+
export type RenderEditState = ReturnType<typeof useRenderEditState>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import { useExtensionFormHost } from "../app/extension-form-submit.js";
|
|
3
|
+
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
4
|
+
import { useDraftStorage } from "../context/draft-storage-context.js";
|
|
5
|
+
import { useTranslation } from "../i18n.js";
|
|
6
|
+
import { useInsideDrawer } from "../inside-drawer.js";
|
|
7
|
+
import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
|
|
8
|
+
import { usePrimitives } from "../primitives.js";
|
|
9
|
+
import { useActionMenuItems } from "./use-action-menu-items.js";
|
|
10
|
+
import { useWizardFormDraftState } from "./use-wizard-form-draft.js";
|
|
11
|
+
function useSubmitFeedbackState() {
|
|
12
|
+
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
13
|
+
const [linkCopied, setLinkCopied] = useState(false);
|
|
14
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
15
|
+
const [focusFirstErrorKey, setFocusFirstErrorKey] = useState(0);
|
|
16
|
+
const [formError, setFormError] = useState(null);
|
|
17
|
+
// Only a submit's validation/server error is cleared by editing; a delete
|
|
18
|
+
// rejection and a version conflict (reload is the way out) stay put.
|
|
19
|
+
const [formErrorSource, setFormErrorSource] = useState("submit");
|
|
20
|
+
// A rejected delete can carry several blocking reasons; the first one is the
|
|
21
|
+
// banner's headline (formError), the rest are listed beneath it.
|
|
22
|
+
const [extraDeleteIssues, setExtraDeleteIssues] = useState([]);
|
|
23
|
+
// One state for all header actions (actions?), not per-button — only one
|
|
24
|
+
// action can be in flight at a time, and this keeps the error surfaced in
|
|
25
|
+
// the shared formError-adjacent banner region instead of inside the
|
|
26
|
+
// button row: a full-width Banner there breaks the
|
|
27
|
+
// flex justify-end action bar.
|
|
28
|
+
const [actionError, setActionError] = useState(null);
|
|
29
|
+
return {
|
|
30
|
+
confirmDeleteOpen,
|
|
31
|
+
setConfirmDeleteOpen,
|
|
32
|
+
linkCopied,
|
|
33
|
+
setLinkCopied,
|
|
34
|
+
isSubmitting,
|
|
35
|
+
setIsSubmitting,
|
|
36
|
+
focusFirstErrorKey,
|
|
37
|
+
setFocusFirstErrorKey,
|
|
38
|
+
formError,
|
|
39
|
+
setFormError,
|
|
40
|
+
formErrorSource,
|
|
41
|
+
setFormErrorSource,
|
|
42
|
+
extraDeleteIssues,
|
|
43
|
+
setExtraDeleteIssues,
|
|
44
|
+
actionError,
|
|
45
|
+
setActionError,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function useStepState() {
|
|
49
|
+
const [rawStep, setRawStep] = useState(0);
|
|
50
|
+
const [passedStepKeys, setPassedStepKeys] = useState(() => new Set());
|
|
51
|
+
const [extensionStepComplete, setExtensionStepComplete] = useState({});
|
|
52
|
+
const reportExtensionStepComplete = useCallback((stepKey, complete) => {
|
|
53
|
+
setExtensionStepComplete((prev) => (prev[stepKey] === true) === complete ? prev : { ...prev, [stepKey]: complete });
|
|
54
|
+
}, []);
|
|
55
|
+
// Tabs have stable ids, so the active tab follows its id: a field change
|
|
56
|
+
// that hides an earlier tab must not shift the index onto another tab.
|
|
57
|
+
const [activeTabId, setActiveTabId] = useState(undefined);
|
|
58
|
+
return {
|
|
59
|
+
rawStep,
|
|
60
|
+
setRawStep,
|
|
61
|
+
passedStepKeys,
|
|
62
|
+
setPassedStepKeys,
|
|
63
|
+
extensionStepComplete,
|
|
64
|
+
reportExtensionStepComplete,
|
|
65
|
+
activeTabId,
|
|
66
|
+
setActiveTabId,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Composed save: extension sections report their dirty state so the Save button
|
|
70
|
+
// activates when only a section changed, and provide a submit handler that runs
|
|
71
|
+
// after the entity write. extensionErrorKey stores the i18n key for a failed
|
|
72
|
+
// section persistence.
|
|
73
|
+
//
|
|
74
|
+
// This is not a second write path for saveDraft()'s data: deriveFormFields
|
|
75
|
+
// skips extension sections entirely, so extension-owned field state never enters
|
|
76
|
+
// `fields` or `controller.getSnapshot().values`; there is no draft-covered state
|
|
77
|
+
// left for persistExtensions() to compete over.
|
|
78
|
+
function useExtensionComposition() {
|
|
79
|
+
const [extensionDirty, setExtensionDirty] = useState(false);
|
|
80
|
+
// Mirror for the leave guard: it must see "saved" before React re-renders.
|
|
81
|
+
const extensionDirtyRef = useRef(false);
|
|
82
|
+
const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
|
|
83
|
+
const [extensionErrorKey, setExtensionErrorKey] = useState(null);
|
|
84
|
+
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(({ anyDirty, hasRegistrations }) => {
|
|
85
|
+
extensionDirtyRef.current = anyDirty;
|
|
86
|
+
setExtensionDirty(anyDirty);
|
|
87
|
+
setHasExtensionRegistrations(hasRegistrations);
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
extensionDirty,
|
|
91
|
+
extensionDirtyRef,
|
|
92
|
+
hasExtensionRegistrations,
|
|
93
|
+
extensionErrorKey,
|
|
94
|
+
setExtensionErrorKey,
|
|
95
|
+
extensionFormRegistry,
|
|
96
|
+
runExtensionSubmits,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export function useRenderEditState({ screen, entityId, translate: translateProp, }) {
|
|
100
|
+
// Translate fallback: when the caller does not provide a translate function,
|
|
101
|
+
// consume the i18next context directly. Otherwise field labels would remain raw
|
|
102
|
+
// keys (`feature:entity:foo:field:title`) without caller wiring.
|
|
103
|
+
// useTranslation throws without a LocaleProvider, which is acceptable because
|
|
104
|
+
// RenderEdit only runs inside a mounted Kumiko app tree.
|
|
105
|
+
const t = useTranslation();
|
|
106
|
+
const translate = translateProp ?? t;
|
|
107
|
+
const dispatcher = useOptionalDispatcher();
|
|
108
|
+
const isWizard = screen.layout.mode === "wizard";
|
|
109
|
+
// Tabs reuse the wizard's step machinery: one section visible at a time,
|
|
110
|
+
// all of them mounted, `currentStep` as the active index. What they do NOT
|
|
111
|
+
// share is navigation — a tab form submits from any tab, so every
|
|
112
|
+
// step-transition path below stays wizard-only.
|
|
113
|
+
const isTabs = screen.layout.mode === "tabs";
|
|
114
|
+
const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
|
|
115
|
+
const isCreateMode = entityId === undefined || entityId === null || entityId === "";
|
|
116
|
+
const draftStorage = useDraftStorage();
|
|
117
|
+
const feedback = useSubmitFeedbackState();
|
|
118
|
+
const stepState = useStepState();
|
|
119
|
+
const draftState = useWizardFormDraftState({
|
|
120
|
+
screenId: screen.id,
|
|
121
|
+
entityId,
|
|
122
|
+
isCreateMode,
|
|
123
|
+
draftStorage,
|
|
124
|
+
});
|
|
125
|
+
const extensions = useExtensionComposition();
|
|
126
|
+
const primitives = usePrimitives();
|
|
127
|
+
const { toMenuItem: menuActionToItem, confirmDialog: menuActionConfirmDialog } = useActionMenuItems({ Dialog: primitives.Dialog, onError: feedback.setActionError });
|
|
128
|
+
const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
|
|
129
|
+
const pageHeaderCompact = usePageHeaderCompact();
|
|
130
|
+
const insideDrawer = useInsideDrawer();
|
|
131
|
+
// Both stepped layouts show one section at a time and keep the rest mounted
|
|
132
|
+
// but inert. A tabs layout on a host without the Tabs primitive has no strip
|
|
133
|
+
// to switch with, so it falls back to rendering every section stacked rather
|
|
134
|
+
// than stranding the user on section 0. Same fallback when the
|
|
135
|
+
// WizardStepGroup wrapper is missing.
|
|
136
|
+
const isStepped = isWizard ||
|
|
137
|
+
(isTabs && primitives.Tabs !== undefined && primitives.WizardStepGroup !== undefined);
|
|
138
|
+
return {
|
|
139
|
+
translate,
|
|
140
|
+
dispatcher,
|
|
141
|
+
isWizard,
|
|
142
|
+
isTabs,
|
|
143
|
+
draftEnabled,
|
|
144
|
+
isCreateMode,
|
|
145
|
+
draftStorage,
|
|
146
|
+
...feedback,
|
|
147
|
+
...stepState,
|
|
148
|
+
draftState,
|
|
149
|
+
...extensions,
|
|
150
|
+
primitives,
|
|
151
|
+
menuActionToItem,
|
|
152
|
+
menuActionConfirmDialog,
|
|
153
|
+
pageHeaderSlotAvailable,
|
|
154
|
+
pageHeaderCompact,
|
|
155
|
+
insideDrawer,
|
|
156
|
+
isStepped,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FormValues } from "../headless/index.ts";
|
|
2
|
+
import type { RenderEditSectioned } from "./render-edit-render-model.ts";
|
|
3
|
+
export type RenderEditSteps = {
|
|
4
|
+
readonly lastStepIndex: number;
|
|
5
|
+
readonly currentStep: number;
|
|
6
|
+
readonly isLastWizardStep: boolean;
|
|
7
|
+
readonly wizardDoneSteps: readonly boolean[] | undefined;
|
|
8
|
+
readonly updateModeStepBarProps: Record<string, never> | {
|
|
9
|
+
readonly doneSteps: readonly boolean[];
|
|
10
|
+
readonly selectableSteps: "all";
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export declare function useRenderEditSteps<TValues extends FormValues, TCtx>({ props, state, form, draft, sections, }: RenderEditSectioned<TValues, TCtx>): RenderEditSteps;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { dryRunParse, isFieldsStepFilled, tabIdAt, wizardStepKey } from "./render-edit-helpers.js";
|
|
3
|
+
function computeCurrentStep(isTabs, activeTabId, rawStep, filteredSections) {
|
|
4
|
+
const lastStepIndex = Math.max(filteredSections.length - 1, 0);
|
|
5
|
+
// Clamped on read, not on write: section visibility is value-dependent, so a
|
|
6
|
+
// stored stepIndex can point past what is currently rendered — that lands on
|
|
7
|
+
// the last step instead of an empty one.
|
|
8
|
+
const activeTabIndex = isTabs && activeTabId !== undefined
|
|
9
|
+
? filteredSections.findIndex((s, i) => tabIdAt(s, i) === activeTabId)
|
|
10
|
+
: -1;
|
|
11
|
+
const currentStep = activeTabIndex !== -1 ? activeTabIndex : Math.min(rawStep, lastStepIndex);
|
|
12
|
+
return { currentStep, lastStepIndex };
|
|
13
|
+
}
|
|
14
|
+
function isStepDone(section, index, passedStepKeys, extensionStepComplete, parse) {
|
|
15
|
+
if (passedStepKeys.has(wizardStepKey(section, index)))
|
|
16
|
+
return true;
|
|
17
|
+
if (section.kind === "fields")
|
|
18
|
+
return isFieldsStepFilled(section, parse);
|
|
19
|
+
return (section.kind === "extension" && extensionStepComplete[wizardStepKey(section, index)] === true);
|
|
20
|
+
}
|
|
21
|
+
export function useRenderEditSteps({ props, state, form, draft, sections, }) {
|
|
22
|
+
const { filteredSections } = sections;
|
|
23
|
+
const { schema } = props;
|
|
24
|
+
const { isWizard, isCreateMode, passedStepKeys, extensionStepComplete } = state;
|
|
25
|
+
const { currentStep, lastStepIndex } = computeCurrentStep(state.isTabs, state.activeTabId, state.rawStep, filteredSections);
|
|
26
|
+
// Kept fresh for patchAndScheduleDraftSave's debounce timer above, which
|
|
27
|
+
// is defined before `currentStep` exists (it depends on `filteredSections`,
|
|
28
|
+
// computed further up from `vm`) and so cannot close over it directly.
|
|
29
|
+
draft.refs.currentStepRef.current = currentStep;
|
|
30
|
+
const isLastWizardStep = currentStep >= lastStepIndex;
|
|
31
|
+
const wizardDoneSteps = useMemo(() => {
|
|
32
|
+
if (!isWizard || isCreateMode)
|
|
33
|
+
return undefined;
|
|
34
|
+
const parse = schema === undefined
|
|
35
|
+
? undefined
|
|
36
|
+
: dryRunParse(schema, { values: form.snapshot.values, fields: form.snapshot.fields });
|
|
37
|
+
return filteredSections.map((section, index) => isStepDone(section, index, passedStepKeys, extensionStepComplete, parse));
|
|
38
|
+
}, [
|
|
39
|
+
isWizard,
|
|
40
|
+
isCreateMode,
|
|
41
|
+
filteredSections,
|
|
42
|
+
passedStepKeys,
|
|
43
|
+
extensionStepComplete,
|
|
44
|
+
schema,
|
|
45
|
+
form.snapshot.values,
|
|
46
|
+
form.snapshot.fields,
|
|
47
|
+
]);
|
|
48
|
+
const updateModeStepBarProps = wizardDoneSteps === undefined
|
|
49
|
+
? {}
|
|
50
|
+
: { doneSteps: wizardDoneSteps, selectableSteps: "all" };
|
|
51
|
+
return { lastStepIndex, currentStep, isLastWizardStep, wizardDoneSteps, updateModeStepBarProps };
|
|
52
|
+
}
|