@cosmicdrift/kumiko-renderer 0.386.0 → 0.388.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/LICENSE +21 -57
- package/dist/app/action-form-body.d.ts +2 -2
- package/dist/app/action-form-body.js +12 -4
- package/dist/app/action-form-shim.d.ts +8 -8
- package/dist/app/action-form-shim.js +20 -22
- package/dist/app/action-form-support.d.ts +1 -0
- package/dist/app/action-form-support.js +1 -1
- package/dist/app/app-features-context.js +4 -4
- package/dist/app/column-renderers.d.ts +7 -9
- package/dist/app/column-renderers.js +15 -18
- package/dist/app/config-edit-body.js +7 -3
- package/dist/app/config-edit-shim.d.ts +7 -8
- package/dist/app/config-edit-shim.js +18 -20
- package/dist/app/custom-screens.d.ts +3 -3
- package/dist/app/custom-screens.js +8 -8
- package/dist/app/dashboard-body.d.ts +2 -2
- package/dist/app/dashboard-body.js +2 -2
- package/dist/app/entity-edit-prefill.d.ts +11 -0
- package/dist/app/entity-edit-prefill.js +18 -13
- package/dist/app/entity-edit-screen.js +4 -4
- package/dist/app/entity-list-screen.js +3 -3
- package/dist/app/extension-form-submit.d.ts +1 -1
- package/dist/app/extension-form-submit.js +14 -15
- package/dist/app/extension-sections.d.ts +29 -28
- package/dist/app/extension-sections.js +10 -10
- package/dist/app/feature-schema.js +11 -11
- package/dist/app/form-schema.js +2 -2
- package/dist/app/header-actions-bar.js +2 -2
- package/dist/app/kumiko-screen.js +8 -10
- package/dist/app/layout-fields.js +1 -1
- package/dist/app/list-facets.js +3 -3
- package/dist/app/nav.d.ts +10 -10
- package/dist/app/nav.js +13 -13
- package/dist/app/projection-detail-body.js +4 -4
- package/dist/app/projection-detail-shim.d.ts +6 -5
- package/dist/app/projection-detail-shim.js +23 -22
- package/dist/app/projection-list-body.js +10 -10
- package/dist/app/projection-list-shim.d.ts +5 -5
- package/dist/app/projection-list-shim.js +17 -18
- package/dist/app/qn.js +10 -12
- package/dist/app/qualify-screen-id.js +1 -1
- package/dist/app/screen-access.js +2 -2
- package/dist/app/screen-shared.js +1 -1
- package/dist/app/secret-mint-body.js +4 -4
- package/dist/app/write-failed-error.js +8 -7
- package/dist/components/edit-mounts.d.ts +2 -2
- package/dist/components/embedded-list-field.js +5 -5
- package/dist/components/grid-cell-for-field.d.ts +1 -1
- package/dist/components/reference-create-dialog.js +3 -3
- package/dist/components/related-list-section.d.ts +1 -1
- package/dist/components/related-list-section.js +15 -2
- package/dist/components/render-edit-action-button.d.ts +1 -3
- package/dist/components/render-edit-action-button.js +2 -2
- package/dist/components/render-edit-logic.js +1 -1
- package/dist/components/render-edit-types.d.ts +6 -6
- package/dist/components/render-edit.js +74 -73
- package/dist/components/render-field.d.ts +7 -7
- package/dist/components/render-field.js +62 -61
- package/dist/components/render-list.d.ts +42 -43
- package/dist/components/render-list.js +93 -84
- package/dist/components/use-wizard-form-draft.js +10 -10
- package/dist/context/dispatcher-context.js +5 -5
- package/dist/context/user-roles-context.js +2 -2
- package/dist/flow-diagram/render.js +9 -1
- package/dist/headless/apex/index.d.ts +1 -2
- package/dist/headless/apex/index.js +5 -6
- package/dist/headless/form/form-controller.js +14 -6
- package/dist/headless/form/types.d.ts +1 -0
- package/dist/headless/form/zod-bridge.js +2 -2
- package/dist/headless/format/index.js +89 -64
- package/dist/headless/format/money.js +1 -1
- package/dist/headless/index.js +2 -2
- package/dist/headless/view-model/edit.js +4 -2
- package/dist/headless/view-model/list.js +184 -150
- package/dist/headless/view-model/types.d.ts +6 -3
- package/dist/hooks/live-refetch-scheduler.js +4 -0
- package/dist/hooks/reference-limits.js +11 -12
- package/dist/hooks/use-list-url-state.d.ts +13 -13
- package/dist/hooks/use-list-url-state.js +21 -22
- package/dist/hooks/use-query.js +3 -3
- package/dist/hooks/use-reference-lookup.d.ts +9 -1
- package/dist/hooks/use-reference-lookup.js +32 -19
- package/dist/hooks/use-store.js +18 -22
- package/dist/hooks/use-stream-handler.js +4 -2
- package/dist/i18n-defaults.js +6 -8
- package/dist/i18n.d.ts +10 -10
- package/dist/i18n.js +25 -25
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -4
- package/dist/live/dispatcher-live.js +2 -2
- package/dist/primitives.d.ts +244 -221
- package/dist/primitives.js +1 -1
- package/dist/sse/live-events.d.ts +5 -5
- package/dist/sse/live-events.js +17 -17
- package/dist/tokens.d.ts +8 -8
- package/dist/tokens.js +16 -17
- package/dist/web/app/browser-locale.d.ts +9 -9
- package/dist/web/app/browser-locale.js +19 -19
- package/dist/web/app/client-plugin.d.ts +44 -52
- package/dist/web/app/client-plugin.js +4 -5
- package/dist/web/app/create-app.d.ts +28 -31
- package/dist/web/app/create-app.js +29 -25
- package/dist/web/app/dashboard-body.js +17 -1
- package/dist/web/app/dashboard-panels.js +12 -2
- package/dist/web/app/document-lang-sync.js +1 -1
- package/dist/web/app/draft-storage.js +1 -1
- package/dist/web/app/nav-providers-context.d.ts +3 -3
- package/dist/web/app/nav-providers-context.js +3 -3
- package/dist/web/app/nav.d.ts +18 -17
- package/dist/web/app/nav.js +66 -64
- package/dist/web/app/rich-content-editor.js +1 -1
- package/dist/web/components/config-cascade.js +17 -18
- package/dist/web/components/config-source-badge.js +4 -4
- package/dist/web/index.d.ts +1 -1
- package/dist/web/index.js +1 -1
- package/dist/web/layout/app-layout.d.ts +9 -9
- package/dist/web/layout/app-layout.js +2 -2
- package/dist/web/layout/avatar.d.ts +4 -3
- package/dist/web/layout/avatar.js +15 -17
- package/dist/web/layout/default-app-shell.d.ts +22 -23
- package/dist/web/layout/editor-panel.js +3 -3
- package/dist/web/layout/nav-tree.js +86 -84
- package/dist/web/layout/profile-menu.d.ts +1 -1
- package/dist/web/layout/shell-header.js +2 -1
- package/dist/web/layout/sidebar-brand.d.ts +7 -7
- package/dist/web/layout/sidebar-user.d.ts +1 -1
- package/dist/web/layout/sidebar-user.js +4 -4
- package/dist/web/layout/sidebar.d.ts +8 -8
- package/dist/web/layout/sidebar.js +7 -7
- package/dist/web/layout/target-resolver-stub.d.ts +5 -5
- package/dist/web/layout/target-resolver-stub.js +20 -20
- package/dist/web/layout/target-url.d.ts +7 -7
- package/dist/web/layout/target-url.js +22 -22
- package/dist/web/layout/theme-toggle.js +4 -5
- package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
- package/dist/web/layout/uniform-bar-controls.js +4 -0
- package/dist/web/layout/workspace-shell.d.ts +21 -21
- package/dist/web/layout/workspace-shell.js +29 -30
- package/dist/web/lib/cn.d.ts +4 -4
- package/dist/web/lib/cn.js +4 -4
- package/dist/web/lib/file-ref-download.js +4 -1
- package/dist/web/primitives/action-menu.d.ts +8 -8
- package/dist/web/primitives/action-menu.js +14 -13
- package/dist/web/primitives/calendar-popover.d.ts +1 -1
- package/dist/web/primitives/calendar-popover.js +10 -10
- package/dist/web/primitives/card-styles.js +1 -1
- package/dist/web/primitives/card.js +2 -2
- package/dist/web/primitives/code-input.d.ts +6 -0
- package/dist/web/primitives/code-input.js +31 -0
- package/dist/web/primitives/combobox.d.ts +12 -12
- package/dist/web/primitives/combobox.js +34 -35
- package/dist/web/primitives/data-table-cell.js +2 -2
- package/dist/web/primitives/data-table.js +6 -6
- package/dist/web/primitives/date-field.js +2 -2
- package/dist/web/primitives/date-input.js +4 -4
- package/dist/web/primitives/default-form.js +2 -2
- package/dist/web/primitives/dropdown-menu.js +14 -15
- package/dist/web/primitives/embedded-list-input.js +3 -3
- package/dist/web/primitives/facet-filter.js +2 -2
- package/dist/web/primitives/field.js +9 -8
- package/dist/web/primitives/file-thumbnail.d.ts +3 -0
- package/dist/web/primitives/file-thumbnail.js +5 -0
- package/dist/web/primitives/form.d.ts +1 -1
- package/dist/web/primitives/form.js +6 -6
- package/dist/web/primitives/input.js +8 -3
- package/dist/web/primitives/layout-elements.js +7 -5
- package/dist/web/primitives/layout.js +2 -2
- package/dist/web/primitives/located-timestamp-input.js +4 -4
- package/dist/web/primitives/money-input.js +13 -17
- package/dist/web/primitives/page-header.js +3 -1
- package/dist/web/primitives/pager.js +1 -1
- package/dist/web/primitives/row-actions.js +6 -7
- package/dist/web/primitives/secret-reveal.js +1 -1
- package/dist/web/primitives/timestamp-input.d.ts +9 -9
- package/dist/web/primitives/timestamp-input.js +21 -21
- package/dist/web/primitives/toast.js +12 -13
- package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
- package/dist/web/primitives/use-focus-on-mount.js +9 -0
- package/dist/web/sse/live-events.d.ts +6 -6
- package/dist/web/sse/live-events.js +3 -3
- package/dist/web/tokens.d.ts +11 -12
- package/dist/web/tokens.js +35 -37
- package/dist/web/version/update-checker.js +14 -13
- package/dist/web/widgets/ai-text-field.js +1 -1
- package/dist/web/widgets/bounded-load-cache.d.ts +1 -1
- package/dist/web/widgets/bounded-load-cache.js +10 -4
- package/dist/web/widgets/charts.d.ts +10 -10
- package/dist/web/widgets/charts.js +8 -8
- package/dist/web/widgets/choice-cards.d.ts +17 -0
- package/dist/web/widgets/choice-cards.js +12 -0
- package/dist/web/widgets/collapsible-section.d.ts +3 -3
- package/dist/web/widgets/collapsible-section.js +7 -7
- package/dist/web/widgets/detail-list.d.ts +5 -3
- package/dist/web/widgets/detail-list.js +5 -3
- package/dist/web/widgets/feed-list.d.ts +2 -2
- package/dist/web/widgets/feed-list.js +2 -2
- package/dist/web/widgets/form-fields.d.ts +14 -14
- package/dist/web/widgets/form-fields.js +14 -13
- package/dist/web/widgets/index.d.ts +1 -0
- package/dist/web/widgets/index.js +5 -4
- package/dist/web/widgets/infinity-list.js +3 -3
- package/dist/web/widgets/mode-switch.d.ts +2 -2
- package/dist/web/widgets/mode-switch.js +2 -2
- package/dist/web/widgets/pdf-preview.js +1 -1
- package/dist/web/widgets/plan-card.d.ts +2 -0
- package/dist/web/widgets/plan-card.js +1 -1
- package/dist/web/widgets/progress-list.d.ts +1 -2
- package/dist/web/widgets/progress-list.js +1 -2
- package/dist/web/widgets/query-table.d.ts +6 -6
- package/dist/web/widgets/result-panel.d.ts +16 -16
- package/dist/web/widgets/result-panel.js +10 -10
- package/dist/web/widgets/section-card.d.ts +3 -3
- package/dist/web/widgets/section-card.js +3 -3
- package/dist/web/widgets/stat.d.ts +4 -4
- package/dist/web/widgets/stat.js +6 -6
- package/dist/web/widgets/states.d.ts +4 -4
- package/dist/web/widgets/states.js +4 -4
- package/dist/web/widgets/upload-zone.js +13 -5
- package/dist/web/widgets/use-draft.d.ts +7 -7
- package/dist/web/widgets/use-draft.js +7 -7
- package/package.json +6 -6
- package/src/changes.json +49 -0
|
@@ -12,14 +12,15 @@ import { shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
|
|
|
12
12
|
import { ListEmptyStateBody } from "./list-empty-state.js";
|
|
13
13
|
import { needsActionConfirm } from "./render-edit-action-button.js";
|
|
14
14
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
15
|
-
// Icon-less actions are promoted too
|
|
15
|
+
// Icon-less actions are promoted too; on phones the call site falls back to a
|
|
16
|
+
// "plus" icon, on desktop the label carries the action.
|
|
16
17
|
function isHeaderPromotableToolbarAction(action) {
|
|
17
18
|
return action.style === "primary" && !needsActionConfirm(action);
|
|
18
19
|
}
|
|
19
20
|
export function RenderList(props) {
|
|
20
21
|
const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, emptyContent, narrowed = false, defaultFilterActive: defaultFilterActiveProp = false, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
|
|
21
|
-
//
|
|
22
|
-
//
|
|
22
|
+
// As in RenderEdit, fall back to the i18next context for translation;
|
|
23
|
+
// otherwise column headers would display raw i18n keys.
|
|
23
24
|
const t = useTranslation();
|
|
24
25
|
const translate = translateProp ?? t;
|
|
25
26
|
const { DataTable, Button, Dialog, Input, Banner, PageHeader } = usePrimitives();
|
|
@@ -37,24 +38,31 @@ export function RenderList(props) {
|
|
|
37
38
|
// The parent may echo our own debounced emits back late (router/URL round
|
|
38
39
|
// trip), possibly after a newer emit already went out; adopting such a stale
|
|
39
40
|
// echo would erase keys typed in between. All unechoed emits are kept, and a
|
|
40
|
-
// matching echo drops itself and every older one.
|
|
41
|
+
// matching echo drops itself and every older one. The render phase only reads
|
|
42
|
+
// the list (a discarded or repeated render must find the echo again); the
|
|
43
|
+
// matched entries are dropped in an effect.
|
|
41
44
|
const pendingEmitsRef = useRef([]);
|
|
42
45
|
if (searchValue !== syncedSearchValue) {
|
|
43
46
|
setSyncedSearchValue(searchValue);
|
|
44
|
-
|
|
45
|
-
if (echoIndex >= 0) {
|
|
46
|
-
pendingEmitsRef.current.splice(0, echoIndex + 1);
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
47
|
+
if (!pendingEmitsRef.current.includes(searchValue ?? "")) {
|
|
49
48
|
setLocalQ(searchValue ?? "");
|
|
50
49
|
pendingEmitsRef.current = [];
|
|
51
50
|
}
|
|
52
51
|
}
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const echoIndex = pendingEmitsRef.current.indexOf(syncedSearchValue ?? "");
|
|
54
|
+
if (echoIndex >= 0)
|
|
55
|
+
pendingEmitsRef.current.splice(0, echoIndex + 1);
|
|
56
|
+
}, [syncedSearchValue]);
|
|
53
57
|
useEffect(() => {
|
|
54
58
|
if (onSearchChange === undefined)
|
|
55
59
|
return;
|
|
56
60
|
if (localQ === (searchValue ?? ""))
|
|
57
61
|
return;
|
|
62
|
+
// A late echo of an older emit changes searchValue but must not re-send
|
|
63
|
+
// the value that is already in flight.
|
|
64
|
+
if (pendingEmitsRef.current.at(-1) === localQ)
|
|
65
|
+
return;
|
|
58
66
|
const timer = setTimeout(() => {
|
|
59
67
|
pendingEmitsRef.current.push(localQ);
|
|
60
68
|
onSearchChange(localQ);
|
|
@@ -62,33 +70,32 @@ export function RenderList(props) {
|
|
|
62
70
|
return () => clearTimeout(timer);
|
|
63
71
|
}, [localQ, searchValue, onSearchChange]);
|
|
64
72
|
const vm = useMemo(() => computeListViewModel({ screen, entity, rows, translate, featureName }), [screen, entity, rows, translate, featureName]);
|
|
65
|
-
//
|
|
66
|
-
// Hooks
|
|
67
|
-
//
|
|
68
|
-
// ReferenceLookupBridge
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// React verwaltet die mounted/unmounted Lifecycles beim Schema-
|
|
72
|
-
// Wechsel über die key-Property automatisch.
|
|
73
|
+
// Reference-field eager loading through bridge components.
|
|
74
|
+
// The Hooks rule requires useReferenceLookup for each reference column,
|
|
75
|
+
// but the number of columns varies with the schema. Each column therefore
|
|
76
|
+
// mounts a ReferenceLookupBridge that publishes its map to local state
|
|
77
|
+
// through onMap. Each bridge makes a constant number of hook calls (one),
|
|
78
|
+
// and React manages its lifecycle when the schema changes via key.
|
|
73
79
|
const referenceColumns = useMemo(() => vm.columns
|
|
74
80
|
.filter((c) => c.type === "reference" && c.refEntity !== undefined)
|
|
75
81
|
.map((c) => ({
|
|
76
82
|
field: c.field,
|
|
77
83
|
refEntity: c.refEntity ?? "",
|
|
78
|
-
//
|
|
79
|
-
//
|
|
84
|
+
// refFeature comes from parseRefTarget in the ViewModel
|
|
85
|
+
// (default = the current featureName).
|
|
80
86
|
refFeature: c.refFeature ?? featureName,
|
|
81
87
|
labelField: c.refLabelField ?? "id",
|
|
82
88
|
})), [vm.columns, featureName]);
|
|
83
89
|
const [referenceLookups, setReferenceLookups] = useState({});
|
|
84
|
-
const handleLookupMap = useCallback((field,
|
|
90
|
+
const handleLookupMap = useCallback((field, lookup) => {
|
|
85
91
|
setReferenceLookups((prev) => {
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
92
|
+
// Skip the update when the lookup is identical. Otherwise the bridge
|
|
93
|
+
// would re-render and send the same map back until React entered
|
|
94
|
+
// an infinite loop.
|
|
95
|
+
const current = prev[field];
|
|
96
|
+
if (current?.map === lookup.map && current.complete === lookup.complete)
|
|
90
97
|
return prev;
|
|
91
|
-
return { ...prev, [field]:
|
|
98
|
+
return { ...prev, [field]: lookup };
|
|
92
99
|
});
|
|
93
100
|
}, []);
|
|
94
101
|
const resolveReferenceLink = useReferenceLinkResolver();
|
|
@@ -98,19 +105,23 @@ export function RenderList(props) {
|
|
|
98
105
|
return vm.columns.map((col) => {
|
|
99
106
|
if (col.type !== "reference")
|
|
100
107
|
return col;
|
|
101
|
-
//
|
|
102
|
-
//
|
|
108
|
+
// An author-declared renderer always takes precedence;
|
|
109
|
+
// the default is used only when none is set.
|
|
103
110
|
if (col.renderer !== undefined)
|
|
104
111
|
return col;
|
|
105
|
-
const
|
|
112
|
+
const published = referenceLookups[col.field];
|
|
113
|
+
const map = published?.map;
|
|
106
114
|
const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${col.refFeature ?? featureName}:${col.refEntity ?? ""}`];
|
|
107
115
|
// No raw id for entities that opt in: a placeholder while the lookup
|
|
108
|
-
// loads, the "unknown" label once
|
|
116
|
+
// loads, the "unknown" label once the complete lookup found nothing. A
|
|
117
|
+
// capped lookup page cannot prove absence, so it keeps the raw id.
|
|
109
118
|
const unresolvedLabel = (id) => unknownKey === undefined
|
|
110
119
|
? id
|
|
111
|
-
:
|
|
120
|
+
: published === undefined
|
|
112
121
|
? EMPTY_VALUE_PLACEHOLDER
|
|
113
|
-
:
|
|
122
|
+
: published.complete
|
|
123
|
+
? translate(unknownKey)
|
|
124
|
+
: id;
|
|
114
125
|
const labelField = col.refLabelField ?? "id";
|
|
115
126
|
const refEntity = col.refEntity ?? "";
|
|
116
127
|
// Only a row the user can see is linkable: resolved by the server
|
|
@@ -145,11 +156,11 @@ export function RenderList(props) {
|
|
|
145
156
|
});
|
|
146
157
|
};
|
|
147
158
|
const renderer = (value, row) => {
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
159
|
+
// Server eager loading: when the server includes _refs, read the display
|
|
160
|
+
// value directly from the resolved row. This avoids a round trip
|
|
161
|
+
// through the bridge map and the limit:200 constraint. The
|
|
162
|
+
// EagerloadedRow type from @cosmicdrift/kumiko-framework/db defines
|
|
163
|
+
// the shape of _refs.
|
|
153
164
|
const resolved = eagerloadedRef(row, col.field);
|
|
154
165
|
if (Array.isArray(resolved) && resolved.length > 0) {
|
|
155
166
|
return resolved.map((r) => String(r[labelField] ?? r["id"] ?? "")).join(", ");
|
|
@@ -157,7 +168,7 @@ export function RenderList(props) {
|
|
|
157
168
|
if (isPlainRecord(resolved)) {
|
|
158
169
|
return String(resolved[labelField] ?? resolved["id"] ?? "");
|
|
159
170
|
}
|
|
160
|
-
// Renderer-
|
|
171
|
+
// Renderer-side fallback when server eager loading is not active.
|
|
161
172
|
if (Array.isArray(value)) {
|
|
162
173
|
if (value.length === 0)
|
|
163
174
|
return EMPTY_VALUE_PLACEHOLDER;
|
|
@@ -182,9 +193,9 @@ export function RenderList(props) {
|
|
|
182
193
|
...vm,
|
|
183
194
|
columns: enrichedColumns,
|
|
184
195
|
}), [vm, enrichedColumns]);
|
|
185
|
-
// i18n
|
|
186
|
-
//
|
|
187
|
-
// (kumiko.actions.create, kumiko.list.search-placeholder, …).
|
|
196
|
+
// i18n defaults for toolbar and empty-state strings. The caller can
|
|
197
|
+
// override each one through a prop; otherwise the framework bundles provide
|
|
198
|
+
// them (kumiko.actions.create, kumiko.list.search-placeholder, …).
|
|
188
199
|
const effectiveCreateLabel = translate(createLabel ?? screen.createLabel ?? "kumiko.actions.create");
|
|
189
200
|
const effectiveSearchPlaceholder = translate(searchPlaceholder ?? screen.searchPlaceholder ?? "kumiko.list.search-placeholder");
|
|
190
201
|
const nounKey = itemNounKey ?? `${featureName}:entity:${screen.entity}:noun`;
|
|
@@ -193,12 +204,12 @@ export function RenderList(props) {
|
|
|
193
204
|
? (count) => translate(nounKey, { count })
|
|
194
205
|
: undefined;
|
|
195
206
|
const toolbarStart = searchable ? (_jsx(Input, { kind: "text", id: "render-list-search", name: "search", value: localQ, onChange: setLocalQ, placeholder: effectiveSearchPlaceholder })) : undefined;
|
|
196
|
-
// Toolbar-
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
207
|
+
// Toolbar-end slot: an optional header slot (for example, a cap counter,
|
|
208
|
+
// at the left of the right-hand cluster), toolbar actions from the schema,
|
|
209
|
+
// and an optional "+ New" button at the right edge. The header slot lives
|
|
210
|
+
// inside the toolbar row, with the same card frame, rather than as loose
|
|
211
|
+
// text above the screen title. Items retain array order, and "+ New" comes
|
|
212
|
+
// last because it is the most common and prominent CTA.
|
|
202
213
|
const hasToolbarActions = toolbarActions !== undefined && toolbarActions.length > 0;
|
|
203
214
|
const hasHeaderSlot = screen.slots?.header !== undefined;
|
|
204
215
|
const toolbarIconOnly = hasToolbarActions && shouldRenderActionsIconOnly(toolbarActions);
|
|
@@ -218,30 +229,29 @@ export function RenderList(props) {
|
|
|
218
229
|
hasToolbarActionsInToolbar ||
|
|
219
230
|
(onCreate !== undefined && !createInPageHeader) ? (_jsxs(_Fragment, { children: [hasHeaderSlot && _jsx(ListHeaderSlotMount, { screen: screen }), hasToolbarActionsInToolbar &&
|
|
220
231
|
toolbarActionsInToolbar.map((a) => (_jsx(ToolbarActionView, { action: a, iconOnly: toolbarIconOnly, Button: Button, Dialog: Dialog, Banner: Banner }, a.id))), !createInPageHeader && createButton] })) : undefined;
|
|
221
|
-
// Empty
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
232
|
+
// Empty state: by default, show a heading, description, and optional CTA
|
|
233
|
+
// button. The caller can replace it completely through emptyState.
|
|
234
|
+
// Without onCreate or an override, DataTable uses its default
|
|
235
|
+
// (`kumiko.list.no-entries`).
|
|
225
236
|
const defaultFilterActive = defaultFilterActiveProp && onFilterReset !== undefined;
|
|
226
237
|
const composedEmptyState = emptyState ??
|
|
227
238
|
(onCreate !== undefined || emptyContent !== undefined || narrowed || defaultFilterActive ? (_jsx(ListEmptyStateBody, { content: emptyContent, narrowed: narrowed, defaultFilterActive: defaultFilterActive, ...(onFilterReset !== undefined && { onShowAll: onFilterReset }), translate: translate, createButton: onCreate !== undefined && !narrowed && !defaultFilterActive && !createInPageHeader ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-empty-create", children: effectiveCreateLabel })) : undefined })) : undefined);
|
|
228
|
-
// Title
|
|
229
|
-
//
|
|
230
|
-
// screenId
|
|
231
|
-
// dann dass die Übersetzung fehlt).
|
|
239
|
+
// Title resolution uses the conventional i18n key `screen:<id>.title`.
|
|
240
|
+
// If the bundle knows the key, use its translated title; otherwise use
|
|
241
|
+
// screenId so the app developer can see that the translation is missing.
|
|
232
242
|
const titleKey = `screen:${screen.id}.title`;
|
|
233
243
|
const resolvedTitle = translate(titleKey);
|
|
234
244
|
const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
|
|
235
|
-
// ListSort = DataTableSort (use-list-url-state
|
|
245
|
+
// ListSort = DataTableSort (aliased in use-list-url-state), so no cast is needed.
|
|
236
246
|
return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), promotedActionButton !== undefined && PageHeader !== undefined && (_jsx(PageHeader, { actions: promotedActionButton })), referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: handleLookupMap }, rc.field))), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...(onRowClick !== undefined && { onRowClick }), ...(composedEmptyState !== undefined && { emptyState: composedEmptyState }), ...(toolbarStart !== undefined && { toolbarStart }), ...(description !== undefined && { toolbarDescription: description }), ...(toolbarEnd !== undefined && { toolbarEnd }), ...(sort !== undefined && { sort }), ...(onSortChange !== undefined && { onSortChange }), ...(pager !== undefined && { pager }), ...(onReachEnd !== undefined && { onReachEnd }), ...(loadingMore !== undefined && { loadingMore }), ...(hasMore !== undefined && { hasMore }), ...(itemNoun !== undefined && { itemNoun }), ...(rowActions !== undefined && { rowActions }), ...(rowActionMode !== undefined && { rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(filterFacets !== undefined && { filterFacets }), ...(filterValues !== undefined && { filterValues }), ...(onFilterChange !== undefined && { onFilterChange }), ...(onFilterReset !== undefined && { onFilterReset }), ...(dateRangeFacets !== undefined && { dateRangeFacets }), ...(onDateRangeChange !== undefined && { onDateRangeChange }), ...(chromeless !== undefined && { chromeless }), ...(scrollBody !== undefined && { scrollBody }), ...(screenPadding !== undefined && { screenPadding }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), testId: "render-list-table" })] }));
|
|
237
247
|
}
|
|
238
|
-
// Header
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// entityId
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
248
|
+
// Header slot in the list toolbar (toolbarEnd): `screen.slots.header` is
|
|
249
|
+
// a PlatformComponent (`{ react: { __component: "X" } }`) resolved through
|
|
250
|
+
// the same ExtensionSectionsProvider registry as entityEdit sections.
|
|
251
|
+
// entityId is null in the list context because there is no row; the component
|
|
252
|
+
// loads its own data (for example, a cap counter from a usage query). A
|
|
253
|
+
// separate component is required by the Rules of Hooks because
|
|
254
|
+
// useExtensionSectionComponent must not run conditionally in RenderList.
|
|
245
255
|
function ListHeaderSlotMount({ screen, }) {
|
|
246
256
|
const header = screen.slots?.header;
|
|
247
257
|
const name = header !== undefined ? extensionSectionName(header) : undefined;
|
|
@@ -261,37 +271,36 @@ function ListHeaderSlotMount({ screen, }) {
|
|
|
261
271
|
// (useListUrlState) — e.g. a tag filter narrowing the list to an id-set.
|
|
262
272
|
return _jsx(Component, { entityName: screen.entity, entityId: null, screenId: screen.id });
|
|
263
273
|
}
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// key-Prop im map-Loop des Parents.
|
|
274
|
+
// Bridge component for each reference column. It is mounted once per
|
|
275
|
+
// column, calls useReferenceLookup unconditionally, and publishes the map
|
|
276
|
+
// to the parent through onMap. React manages mounting and unmounting when
|
|
277
|
+
// the schema changes through the key prop in the parent's map loop.
|
|
269
278
|
function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMap, }) {
|
|
270
279
|
const lookup = useReferenceLookup(featureName, refEntity, labelField);
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
// "not resolved", an empty published map
|
|
280
|
+
// Use an effect rather than calling directly so setState runs outside the
|
|
281
|
+
// render path; otherwise React warns about updating one component while
|
|
282
|
+
// rendering another.
|
|
283
|
+
// Do not publish while loading or after a failed lookup: a missing map means
|
|
284
|
+
// "not resolved", whereas an empty published map means "nothing found".
|
|
285
|
+
// Only the latter may display "Unknown".
|
|
276
286
|
const resolved = !lookup.loading && !lookup.failed;
|
|
277
287
|
useEffect(() => {
|
|
278
288
|
if (resolved)
|
|
279
|
-
onMap(field, lookup.map);
|
|
280
|
-
}, [field, lookup.map, resolved, onMap]);
|
|
289
|
+
onMap(field, { map: lookup.map, complete: lookup.complete });
|
|
290
|
+
}, [field, lookup.map, lookup.complete, resolved, onMap]);
|
|
281
291
|
return null;
|
|
282
292
|
}
|
|
283
|
-
// ToolbarActionView
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
293
|
+
// ToolbarActionView renders one button per toolbar action, with a confirm
|
|
294
|
+
// dialog when required. It follows the inline RowAction pattern but has no
|
|
295
|
+
// row context. The busy state covers async onTrigger; the dialog opens before
|
|
296
|
+
// the trigger when confirmation is required for confirm/danger actions.
|
|
287
297
|
function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }) {
|
|
288
298
|
const [busy, setBusy] = useState(false);
|
|
289
299
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
290
|
-
// Toolbar
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
// sieht für den User wie "nichts passiert" aus.
|
|
300
|
+
// Toolbar actions do not have access to the toast primitive, unlike
|
|
301
|
+
// rowActions routed through the DataTable primitive, so an inline error
|
|
302
|
+
// banner is used to surface failures. Without it, a swallowed failure result
|
|
303
|
+
// would look to the user as though nothing happened.
|
|
295
304
|
const [errorText, setErrorText] = useState(null);
|
|
296
305
|
const trigger = async () => {
|
|
297
306
|
setBusy(true);
|
|
@@ -309,7 +318,7 @@ function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }
|
|
|
309
318
|
const variant = action.style ?? "secondary";
|
|
310
319
|
const needsConfirm = needsActionConfirm(action);
|
|
311
320
|
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
312
|
-
return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "
|
|
321
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "md", ...(showIconOnly && { ariaLabel: action.label, title: action.label }), onClick: () => {
|
|
313
322
|
if (needsConfirm) {
|
|
314
323
|
setConfirmOpen(true);
|
|
315
324
|
}
|
|
@@ -12,10 +12,10 @@ const FORM_DRAFT_LIST = "form-draft:query:list";
|
|
|
12
12
|
// Mirrors bundled-features/src/form-draft/web/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
|
|
13
13
|
// — same hardcoding rationale as the QNs above (no dependency on that package).
|
|
14
14
|
const FORM_DRAFT_KEY_MAX_LENGTH = 256;
|
|
15
|
-
// Trailing-edge debounce for patch()-triggered draft saves
|
|
15
|
+
// Trailing-edge debounce for patch()-triggered draft saves. A single
|
|
16
16
|
// patch() call (VIN-decode, an extension section) should not save immediately
|
|
17
17
|
// per call, but patch() can also fire from inside onChange on every keystroke
|
|
18
|
-
// (the
|
|
18
|
+
// (the derived-field shape) — 500ms collapses a typing burst into one
|
|
19
19
|
// save instead of one per keystroke, while still saving well before a user
|
|
20
20
|
// abandons the tab.
|
|
21
21
|
export const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
|
|
@@ -40,7 +40,7 @@ function mintDraftId() {
|
|
|
40
40
|
return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
41
41
|
}
|
|
42
42
|
export function useWizardFormDraftState({ screenId, entityId, isCreateMode, draftStorage, }) {
|
|
43
|
-
// Create-mode draftId
|
|
43
|
+
// Create-mode draftId — resumed from `sessionStorage` (web)
|
|
44
44
|
// on mount so a same-tab reload finds the right one of several parallel
|
|
45
45
|
// create-sessions on this screen; `null` until the first step change
|
|
46
46
|
// mints one (saveDraft), or the mount-time `form-draft:query:list`
|
|
@@ -55,7 +55,7 @@ export function useWizardFormDraftState({ screenId, entityId, isCreateMode, draf
|
|
|
55
55
|
// once for this mount. Without this, a create-mode discard resets `draftId`
|
|
56
56
|
// to `null` (see discardDraft below) and would re-arm the list effect —
|
|
57
57
|
// silently adopting an unrelated parallel draft on the same screen right
|
|
58
|
-
// after the user just submitted
|
|
58
|
+
// after the user just submitted.
|
|
59
59
|
const didListRef = useRef(false);
|
|
60
60
|
const [draftCandidates, setDraftCandidates] = useState(null);
|
|
61
61
|
// Derived from the screen id + the host entity id only — never from
|
|
@@ -64,7 +64,7 @@ export function useWizardFormDraftState({ screenId, entityId, isCreateMode, draf
|
|
|
64
64
|
// unchanged. Create-mode: `${screenId}:new:${draftId}` once a draftId
|
|
65
65
|
// exists (resumed, adopted, or minted), `undefined` before that — two
|
|
66
66
|
// parallel create sessions on the same screen must never collapse onto
|
|
67
|
-
// the same key
|
|
67
|
+
// the same key.
|
|
68
68
|
const draftKey = useMemo(() => isCreateMode
|
|
69
69
|
? draftId !== null
|
|
70
70
|
? `${screenId}:new:${draftId}`
|
|
@@ -133,7 +133,7 @@ export function useWizardFormDraftRestore({ screenId, isCreateMode, draftEnabled
|
|
|
133
133
|
hasNavigatedRef,
|
|
134
134
|
setRawStep,
|
|
135
135
|
]);
|
|
136
|
-
// Mount-time fallback
|
|
136
|
+
// Mount-time fallback for create-mode when no draftId
|
|
137
137
|
// survived in storage (new tab, cleared storage): ask the server for this
|
|
138
138
|
// screen's open drafts and let the user choose via a picker (below) —
|
|
139
139
|
// even for exactly one candidate. Auto-adopting a lone candidate would
|
|
@@ -186,7 +186,7 @@ export function buildWizardDraftActions({ screenId, isCreateMode, draftEnabled,
|
|
|
186
186
|
// (see draftCandidates below) — adopt it the same way a single
|
|
187
187
|
// auto-adopted candidate would be.
|
|
188
188
|
function adoptDraft(candidate) {
|
|
189
|
-
// Locked state
|
|
189
|
+
// Locked state: `disabled` means "no write possible" —
|
|
190
190
|
// repointing draftKey and patching in the candidate's values is exactly
|
|
191
191
|
// that, so a locked form must not adopt a draft.
|
|
192
192
|
// skip: a locked form keeps its current draft (reason above).
|
|
@@ -202,8 +202,8 @@ export function buildWizardDraftActions({ screenId, isCreateMode, draftEnabled,
|
|
|
202
202
|
//
|
|
203
203
|
// Create-mode, first draft save (step change OR a debounced patch() from
|
|
204
204
|
// controlled mode / extension sections, see patchAndScheduleDraftSave
|
|
205
|
-
// above — both call this on step 0 too): mints the draftId here
|
|
206
|
-
//
|
|
205
|
+
// above — both call this on step 0 too): mints the draftId here
|
|
206
|
+
// rather than at mount, so a form nobody ever interacted with
|
|
207
207
|
// never claims a draftId or writes a row. `draftKey`/`draftId` state
|
|
208
208
|
// won't reflect the mint until the next render, so the just-minted key is
|
|
209
209
|
// computed inline instead of read from the memoized `draftKey`.
|
|
@@ -220,7 +220,7 @@ export function buildWizardDraftActions({ screenId, isCreateMode, draftEnabled,
|
|
|
220
220
|
key = `${screenId}:new:${mintedId}`;
|
|
221
221
|
// A stale picker from the mount-time list fallback must not survive a
|
|
222
222
|
// mint — picking a candidate afterwards would repoint draftKey at an
|
|
223
|
-
// unrelated draft mid-edit and overwrite it
|
|
223
|
+
// unrelated draft mid-edit and overwrite it.
|
|
224
224
|
setDraftCandidates(null);
|
|
225
225
|
}
|
|
226
226
|
// skip: create-mode with a draftId that failed to mint — unreachable
|
|
@@ -32,11 +32,11 @@ export function useDispatcher() {
|
|
|
32
32
|
}
|
|
33
33
|
return dispatcher;
|
|
34
34
|
}
|
|
35
|
-
// Soft
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
35
|
+
// Soft variant: returns null when no provider is mounted instead of throwing. For
|
|
36
|
+
// components that optionally need a Dispatcher (for example, KumikoScreen renders
|
|
37
|
+
// with or without rowActions; the test suite does not mount a Dispatcher when the
|
|
38
|
+
// screen triggers no mutation). Callers must provide a sensible fallback for null
|
|
39
|
+
// (typically, no action wiring).
|
|
40
40
|
export function useOptionalDispatcher() {
|
|
41
41
|
return useContext(DispatcherContext) ?? undefined;
|
|
42
42
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext } from "react";
|
|
3
3
|
// Threads the current user's roles through the tree so KumikoScreen can
|
|
4
|
-
// gate role-restricted screens at render time
|
|
5
|
-
//
|
|
4
|
+
// gate role-restricted screens at render time — nav filtering alone
|
|
5
|
+
// doesn't stop a direct-URL/screenQn hit on a role-gated screen).
|
|
6
6
|
// Apps wire this from their `shell` render-prop, the same place they
|
|
7
7
|
// already pass `user` to WorkspaceShell for nav filtering.
|
|
8
8
|
//
|
|
@@ -23,7 +23,15 @@ function cardSize(node) {
|
|
|
23
23
|
return { width, height };
|
|
24
24
|
}
|
|
25
25
|
function assertEdgesReferenceKnownNodes(spec) {
|
|
26
|
-
const ids = new Set(
|
|
26
|
+
const ids = new Set();
|
|
27
|
+
for (const node of spec.nodes) {
|
|
28
|
+
if (ids.has(node.id))
|
|
29
|
+
throw new Error(`FlowSpec: duplicate node id "${node.id}"`);
|
|
30
|
+
ids.add(node.id);
|
|
31
|
+
if (node.icon !== undefined && node.icon !== "ai" && !Object.hasOwn(ICONS, node.icon)) {
|
|
32
|
+
throw new Error(`FlowSpec: node "${node.id}" has unknown icon "${node.icon}"`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
27
35
|
for (const edge of spec.edges) {
|
|
28
36
|
if (!ids.has(edge.from))
|
|
29
37
|
throw new Error(`FlowSpec: edge references unknown node "${edge.from}"`);
|
|
@@ -231,6 +231,5 @@ export declare function injectPageHead(html: string, tagsHtml: string): string;
|
|
|
231
231
|
* the shared timeout. Never throws and never changes `html` on a resolver
|
|
232
232
|
* error, a `null` resolve, or a timeout — the caller's shell always ships
|
|
233
233
|
* unchanged. runProdApp and runDevApp both call this single function, so a
|
|
234
|
-
* future change to timeout or fallback semantics has one place to land
|
|
235
|
-
* (kumiko-framework#3026). */
|
|
234
|
+
* future change to timeout or fallback semantics has one place to land. */
|
|
236
235
|
export declare function resolveAndInjectPageHead(html: string, resolvePageHead: PageHeadResolver, input: PageHeadResolveInput): Promise<string>;
|
|
@@ -8,8 +8,8 @@ import { APEX_STRUCTURAL_CSS } from "./css.js";
|
|
|
8
8
|
import { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT } from "./lightbox.js";
|
|
9
9
|
export { APEX_NAV_MENU_CSS, APEX_NAV_TOGGLE_RESPONSIVE_CSS, APEX_STRUCTURAL_CSS } from "./css.js";
|
|
10
10
|
export { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT, APEX_LIGHTBOX_SCRIPT_CSP_HASH, } from "./lightbox.js";
|
|
11
|
-
// JSON in <script
|
|
12
|
-
// `</script>`
|
|
11
|
+
// JSON in <script> context: serialize `<` as < so neither
|
|
12
|
+
// `</script>` nor `<!--` can escape the block (JSON remains valid).
|
|
13
13
|
function scriptSafeJsonHtml(value) {
|
|
14
14
|
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
15
15
|
}
|
|
@@ -265,7 +265,7 @@ export function renderApexHeadTags(head) {
|
|
|
265
265
|
export function renderApexPage(page) {
|
|
266
266
|
const { head, brand } = page;
|
|
267
267
|
const theme = page.theme ?? "light";
|
|
268
|
-
// Brand
|
|
268
|
+
// Brand CSS is app-authored (see the file header for the trust boundary), not tenant input.
|
|
269
269
|
const cssHtml = (brand.fontFaceCss ?? "") + brand.tokensCss + APEX_STRUCTURAL_CSS;
|
|
270
270
|
const sectionsHtml = page.sections.map(renderSection).join("\n\n ");
|
|
271
271
|
// interactive-widget is ignored by iOS Safari; apex pages have no fixed bottom bars, so
|
|
@@ -308,7 +308,7 @@ export function injectPageHead(html, tagsHtml) {
|
|
|
308
308
|
// Resolver runs alongside the request, never gates it: a slow, throwing, or
|
|
309
309
|
// null-returning resolver must never turn a 200 shell into a 500 or a
|
|
310
310
|
// stall. One constant shared by runProdApp and runDevApp so both time out
|
|
311
|
-
// on the same value
|
|
311
|
+
// on the same value.
|
|
312
312
|
const PAGE_HEAD_TIMEOUT_MS = 300;
|
|
313
313
|
async function resolvePageHeadWithTimeout(resolvePageHead, input) {
|
|
314
314
|
let timer;
|
|
@@ -367,8 +367,7 @@ function toApexHead(meta) {
|
|
|
367
367
|
* the shared timeout. Never throws and never changes `html` on a resolver
|
|
368
368
|
* error, a `null` resolve, or a timeout — the caller's shell always ships
|
|
369
369
|
* unchanged. runProdApp and runDevApp both call this single function, so a
|
|
370
|
-
* future change to timeout or fallback semantics has one place to land
|
|
371
|
-
* (kumiko-framework#3026). */
|
|
370
|
+
* future change to timeout or fallback semantics has one place to land. */
|
|
372
371
|
export async function resolveAndInjectPageHead(html, resolvePageHead, input) {
|
|
373
372
|
const meta = await resolvePageHeadWithTimeout(resolvePageHead, input);
|
|
374
373
|
if (!meta)
|
|
@@ -68,7 +68,7 @@ function stripUntouchedEmptyStrings(values, initial, forceIncludeEmpty) {
|
|
|
68
68
|
let out;
|
|
69
69
|
for (const key of Object.keys(v)) {
|
|
70
70
|
// Value-based seed strip, except keys previously submitted as "" after a
|
|
71
|
-
// real clear — those must survive post-rebase submits
|
|
71
|
+
// real clear — those must survive post-rebase submits.
|
|
72
72
|
if (!forceIncludeEmpty.has(key) && v[key] === "" && ini[key] === "") {
|
|
73
73
|
out ??= { ...v };
|
|
74
74
|
delete out[key];
|
|
@@ -105,7 +105,7 @@ export function createFormController(options) {
|
|
|
105
105
|
let initial = { ...options.initial };
|
|
106
106
|
let errors = Object.freeze({});
|
|
107
107
|
// Keys last submitted as "" (intentional clear). Survives rebase so a later
|
|
108
|
-
// submit of other fields does not drop them
|
|
108
|
+
// submit of other fields does not drop them.
|
|
109
109
|
const forceIncludeEmpty = new Set();
|
|
110
110
|
// ctx is cast-held as TCtx; `undefined` is valid when callers don't
|
|
111
111
|
// declare conditional predicates that depend on it.
|
|
@@ -166,7 +166,7 @@ export function createFormController(options) {
|
|
|
166
166
|
function runValidate(scope, opts) {
|
|
167
167
|
// See the validate() doc comment in types.ts for the scope contract.
|
|
168
168
|
// A scope entry matches its exact path and everything below it, but not
|
|
169
|
-
// siblings: `address.city` must not pull in `address.zip
|
|
169
|
+
// siblings: `address.city` must not pull in `address.zip`.
|
|
170
170
|
const isInScope = scope === undefined
|
|
171
171
|
? undefined
|
|
172
172
|
: (path) => scope.some((entry) => path === entry || path.startsWith(`${entry}.`));
|
|
@@ -193,8 +193,8 @@ export function createFormController(options) {
|
|
|
193
193
|
hiddenFields.add(fieldKey);
|
|
194
194
|
}
|
|
195
195
|
// Root-level .refine() issues use path "(root)". Wizard step validate()
|
|
196
|
-
// still drops them
|
|
197
|
-
// fields={…} cannot silently skip cross-field rules
|
|
196
|
+
// still drops them; submit() passes includeRoot so RenderEdit
|
|
197
|
+
// fields={…} cannot silently skip cross-field rules.
|
|
198
198
|
const relevantIssues = relevantFieldIssues(zodErrorToFieldIssues(parsed.error), {
|
|
199
199
|
hiddenFields,
|
|
200
200
|
...(scope !== undefined && { scope }),
|
|
@@ -217,6 +217,13 @@ export function createFormController(options) {
|
|
|
217
217
|
errors = Object.freeze({});
|
|
218
218
|
invalidate();
|
|
219
219
|
}
|
|
220
|
+
function runRebaseFields(fields) {
|
|
221
|
+
initial = {
|
|
222
|
+
...initial,
|
|
223
|
+
...Object.fromEntries(fields.map((field) => [field, values[field]])),
|
|
224
|
+
};
|
|
225
|
+
invalidate();
|
|
226
|
+
}
|
|
220
227
|
// Stale-submit-safe rebase. Difference to runRebase: the baseline is
|
|
221
228
|
// taken from the values that were actually SENT to the server, not
|
|
222
229
|
// from `values` at the moment the server replied. If the user typed
|
|
@@ -296,6 +303,7 @@ export function createFormController(options) {
|
|
|
296
303
|
invalidate();
|
|
297
304
|
},
|
|
298
305
|
rebase: runRebase,
|
|
306
|
+
rebaseFields: runRebaseFields,
|
|
299
307
|
setCtx(nextCtx) {
|
|
300
308
|
// Cast back to TCtx: setCtx's public signature takes unknown so
|
|
301
309
|
// callers don't need to plumb generics. If conditions depend on ctx
|
|
@@ -382,7 +390,7 @@ export function createFormController(options) {
|
|
|
382
390
|
const result = await dispatcher.write(submitCfg.type, payload, { requestId });
|
|
383
391
|
unansweredAttempt = isTransportFailure(result) ? { requestId, fingerprint } : null;
|
|
384
392
|
if (result.isSuccess) {
|
|
385
|
-
// Remember intentional empty clears so post-rebase submits keep them
|
|
393
|
+
// Remember intentional empty clears so post-rebase submits keep them.
|
|
386
394
|
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
387
395
|
for (const [k, v] of Object.entries(payload)) {
|
|
388
396
|
if (v === "")
|
|
@@ -33,6 +33,7 @@ export type FormController<TValues extends FormValues> = {
|
|
|
33
33
|
validate(scope?: readonly FormScopeKey<TValues>[]): boolean;
|
|
34
34
|
reset(): void;
|
|
35
35
|
rebase(): void;
|
|
36
|
+
rebaseFields(fields: readonly string[]): void;
|
|
36
37
|
setCtx(ctx: unknown): void;
|
|
37
38
|
submit<TData = unknown>(): Promise<SubmitResult<TData>>;
|
|
38
39
|
};
|
|
@@ -46,8 +46,8 @@ export function zodErrorToFieldIssues(error) {
|
|
|
46
46
|
}
|
|
47
47
|
// The issues that count for validity: hidden fields never block, and a
|
|
48
48
|
// scope narrows to the listed paths (an entry matches itself and everything
|
|
49
|
-
// below it, not siblings
|
|
50
|
-
// inside a scope (submit passes it
|
|
49
|
+
// below it, not siblings). `includeRoot` keeps "(root)" refine issues
|
|
50
|
+
// inside a scope (submit passes it). Shared by the controller's
|
|
51
51
|
// validate() and RenderEdit's `valid` dry-run so the two cannot drift.
|
|
52
52
|
export function relevantFieldIssues(issues, opts) {
|
|
53
53
|
const { hiddenFields, scope, includeRoot } = opts;
|