@cosmicdrift/kumiko-renderer 0.384.0 → 0.385.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/action-form-body.d.ts +50 -0
- package/dist/app/action-form-body.js +275 -0
- package/dist/app/client-actions.js +2 -2
- package/dist/app/config-edit-body.d.ts +9 -0
- package/dist/app/config-edit-body.js +340 -0
- package/dist/app/entity-edit-prefill.d.ts +27 -0
- package/dist/app/entity-edit-prefill.js +377 -0
- package/dist/app/entity-edit-screen.d.ts +11 -0
- package/dist/app/entity-edit-screen.js +395 -0
- package/dist/app/entity-list-screen.d.ts +10 -0
- package/dist/app/entity-list-screen.js +547 -0
- package/dist/app/kumiko-screen.d.ts +1 -36
- package/dist/app/kumiko-screen.js +16 -2855
- package/dist/app/projection-detail-body.d.ts +12 -0
- package/dist/app/projection-detail-body.js +464 -0
- package/dist/app/projection-detail-shim.d.ts +1 -1
- package/dist/app/projection-detail-shim.js +5 -1
- package/dist/app/projection-list-body.d.ts +10 -0
- package/dist/app/projection-list-body.js +245 -0
- package/dist/app/projection-list-shim.d.ts +2 -1
- package/dist/app/projection-list-shim.js +4 -0
- package/dist/app/reference-facet-bridge.js +2 -2
- package/dist/app/row-actions.js +3 -4
- package/dist/app/screen-shared.d.ts +72 -0
- package/dist/app/screen-shared.js +325 -0
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/components/edit-mounts.d.ts +33 -0
- package/dist/components/edit-mounts.js +53 -0
- package/dist/components/error-boundary.js +2 -2
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +2 -2
- package/dist/components/render-edit-helpers.d.ts +29 -0
- package/dist/components/render-edit-helpers.js +121 -0
- package/dist/components/render-edit.d.ts +1 -4
- package/dist/components/render-edit.js +41 -443
- package/dist/components/render-field.js +3 -4
- package/dist/components/render-list.js +2 -2
- package/dist/components/use-wizard-form-draft.d.ts +51 -0
- package/dist/components/use-wizard-form-draft.js +291 -0
- package/dist/dev-console.d.ts +3 -0
- package/dist/dev-console.js +15 -0
- package/dist/headless/contracts/primitives.js +14 -17
- package/dist/headless/format/index.js +3 -3
- package/dist/headless/view-model/edit.d.ts +1 -1
- package/dist/headless/view-model/edit.js +9 -16
- package/dist/headless/view-model/list.d.ts +1 -1
- package/dist/headless/view-model/list.js +3 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -5
- package/dist/live/error-mapping.js +3 -4
- package/dist/web/app/create-app.js +5 -8
- package/dist/web/app/dashboard-body.js +4 -599
- package/dist/web/app/dashboard-panels.d.ts +53 -0
- package/dist/web/app/dashboard-panels.js +373 -0
- package/dist/web/app/dashboard-shared.d.ts +90 -0
- package/dist/web/app/dashboard-shared.js +171 -0
- package/dist/web/app/dashboard-text.d.ts +6 -0
- package/dist/web/app/dashboard-text.js +65 -0
- package/dist/web/layout/nav-tree.js +3 -4
- package/dist/web/layout/target-resolver-stub.js +2 -2
- package/dist/web/primitives/banner.d.ts +4 -0
- package/dist/web/primitives/banner.js +28 -0
- package/dist/web/primitives/button.d.ts +7 -0
- package/dist/web/primitives/button.js +48 -0
- package/dist/web/primitives/card-styles.d.ts +8 -0
- package/dist/web/primitives/card-styles.js +18 -0
- package/dist/web/primitives/card.d.ts +4 -0
- package/dist/web/primitives/card.js +41 -0
- package/dist/web/primitives/contexts.d.ts +22 -0
- package/dist/web/primitives/contexts.js +50 -0
- package/dist/web/primitives/data-table-cell.d.ts +76 -0
- package/dist/web/primitives/data-table-cell.js +403 -0
- package/dist/web/primitives/data-table.d.ts +4 -0
- package/dist/web/primitives/data-table.js +279 -0
- package/dist/web/primitives/default-form.d.ts +4 -0
- package/dist/web/primitives/default-form.js +79 -0
- package/dist/web/primitives/facet-filter.d.ts +14 -0
- package/dist/web/primitives/facet-filter.js +39 -0
- package/dist/web/primitives/field.d.ts +63 -0
- package/dist/web/primitives/field.js +181 -0
- package/dist/web/primitives/form.d.ts +53 -0
- package/dist/web/primitives/form.js +252 -0
- package/dist/web/primitives/index.d.ts +7 -30
- package/dist/web/primitives/index.js +27 -2006
- package/dist/web/primitives/input.d.ts +4 -0
- package/dist/web/primitives/input.js +166 -0
- package/dist/web/primitives/layout-elements.d.ts +14 -0
- package/dist/web/primitives/layout-elements.js +194 -0
- package/dist/web/primitives/modal.js +1 -1
- package/dist/web/primitives/pager.d.ts +22 -0
- package/dist/web/primitives/pager.js +89 -0
- package/dist/web/primitives/row-actions.d.ts +13 -0
- package/dist/web/primitives/row-actions.js +178 -0
- package/dist/web/primitives/secret-reveal.d.ts +4 -0
- package/dist/web/primitives/secret-reveal.js +67 -0
- package/package.json +5 -5
- package/src/changes.json +6 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
|
+
import { useCallback, useEffect, useMemo } from "react";
|
|
5
|
+
import { extractIdField } from "../components/reference-create-dialog.js";
|
|
6
|
+
import { rendererWarn } from "../dev-console.js";
|
|
7
|
+
import { applyFormatSpec } from "../headless/index.js";
|
|
8
|
+
import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
|
|
9
|
+
import { PAGE_SIZE_OPTIONS } from "../hooks/use-list-url-state.js";
|
|
10
|
+
import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
|
|
11
|
+
import { useTranslation } from "../i18n.js";
|
|
12
|
+
import { usePrimitives } from "../primitives.js";
|
|
13
|
+
import { ISO_DATE_PATTERN, isMoneyValue } from "./entity-edit-prefill.js";
|
|
14
|
+
import { useNav } from "./nav.js";
|
|
15
|
+
import { lastSegment, toKebab } from "./qn.js";
|
|
16
|
+
import { featureNameFromQualifiedScreenId } from "./qualify-screen-id.js";
|
|
17
|
+
import { navigateToReturn, navigateWithReturnTo, useReturnHost } from "./return-to.js";
|
|
18
|
+
function ScreenMissingHomeButton({ homeScreenId, testId, }) {
|
|
19
|
+
const { Button } = usePrimitives();
|
|
20
|
+
const t = useTranslation();
|
|
21
|
+
const nav = useNav();
|
|
22
|
+
return (_jsx(Button, { variant: "secondary", onClick: () => nav.navigate({ screenId: homeScreenId }), testId: testId, children: t("kumiko.screen-missing.home") }));
|
|
23
|
+
}
|
|
24
|
+
/** Empty state for a screen QN that is not in the schema (feature disabled for
|
|
25
|
+
* the tenant, stale link, miswired screen). The QN only goes to the console. */
|
|
26
|
+
export function ScreenMissingState({ screenQn, homeScreenId, testId = "kumiko-screen-not-found", }) {
|
|
27
|
+
const { Card, Text } = usePrimitives();
|
|
28
|
+
const t = useTranslation();
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
rendererWarn(`[kumiko] screen "${screenQn}" is not in the schema (feature disabled, stale link or miswired screen).`);
|
|
31
|
+
}, [screenQn]);
|
|
32
|
+
return (_jsx(Card, { options: { padded: true }, slots: {
|
|
33
|
+
title: _jsx(Text, { testId: `${testId}-title`, children: t("kumiko.screen-missing.title") }),
|
|
34
|
+
subtitle: _jsx(Text, { variant: "muted", children: t("kumiko.screen-missing.hint") }),
|
|
35
|
+
}, testId: testId, children: homeScreenId !== undefined ? (_jsx(ScreenMissingHomeButton, { homeScreenId: homeScreenId, testId: `${testId}-home` })) : null }));
|
|
36
|
+
}
|
|
37
|
+
export function ScreenAccessDeniedBanner({ screenQn, testId, }) {
|
|
38
|
+
const { Banner } = usePrimitives();
|
|
39
|
+
const t = useTranslation();
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
rendererWarn(`[kumiko] access to screen "${screenQn}" denied for the current roles.`);
|
|
42
|
+
}, [screenQn]);
|
|
43
|
+
return (_jsx(Banner, { padded: true, variant: "error", testId: testId, children: t("kumiko.screen-access-denied.title") }));
|
|
44
|
+
}
|
|
45
|
+
// ---- entity-edit ----
|
|
46
|
+
// Derives `<feature>:write:<entity>:<verb>` from the screen's entity
|
|
47
|
+
// and the schema's feature name. Matches qualifyEntityName / toKebab in
|
|
48
|
+
// packages/framework/src/engine/qualified-name.ts so camelCase entity
|
|
49
|
+
// ids (e.g. driverModel) resolve to server QNs (driver-model:create).
|
|
50
|
+
export function entityWriteCommand(featureName, entity, verb) {
|
|
51
|
+
return `${toKebab(featureName)}:write:${toKebab(entity)}:${verb}`;
|
|
52
|
+
}
|
|
53
|
+
// Default "success -> back to the list" navigation. Finds the first
|
|
54
|
+
// entityList screen for the entity and navigates there. Create, update and
|
|
55
|
+
// delete all use it: each of them ends with "done editing, leave" as the
|
|
56
|
+
// sensible default.
|
|
57
|
+
export function findEntityListScreen(schema, entityName) {
|
|
58
|
+
return schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
|
|
59
|
+
}
|
|
60
|
+
// Unknown or deleted record id: a localized empty state with a way back
|
|
61
|
+
// instead of a bare error banner. Composed from shared primitives so web and
|
|
62
|
+
// native render it alike.
|
|
63
|
+
export function RecordNotFoundState({ translate, onBack, }) {
|
|
64
|
+
const { Card, Text, Button } = usePrimitives();
|
|
65
|
+
return (_jsxs(Card, { options: { padded: true }, testId: "kumiko-screen-record-missing", children: [_jsx(Text, { testId: "kumiko-screen-record-missing-title", children: translate("kumiko.record.not-found.title") }), _jsx(Text, { variant: "muted", children: translate("kumiko.record.not-found.hint") }), onBack !== undefined && (_jsx(Button, { variant: "secondary", onClick: onBack, testId: "kumiko-screen-record-missing-back", children: translate("kumiko.record.not-found.back") }))] }));
|
|
66
|
+
}
|
|
67
|
+
export function useNavigateToListAfter(schema, entityName) {
|
|
68
|
+
const nav = useNav();
|
|
69
|
+
return useCallback(() => {
|
|
70
|
+
const list = findEntityListScreen(schema, entityName);
|
|
71
|
+
if (!list)
|
|
72
|
+
return;
|
|
73
|
+
// schema.screens.id is in QN form (registry-stamped); nav.navigate
|
|
74
|
+
// expects the short form. Otherwise the URL ends up double-qualified.
|
|
75
|
+
nav.navigate({ screenId: lastSegment(list.id) });
|
|
76
|
+
}, [nav, schema, entityName]);
|
|
77
|
+
}
|
|
78
|
+
// Default "+ New" navigation for the list toolbar: finds the first
|
|
79
|
+
// entityEdit screen without an entityId attachment and navigates there.
|
|
80
|
+
// Returns undefined when no edit screen is registered, so RenderList
|
|
81
|
+
// renders no "+ New" button.
|
|
82
|
+
//
|
|
83
|
+
// Schema screens come from the registry with qualified ids
|
|
84
|
+
// ("publicstatus:screen:component-edit"); lastSegment strips the prefix
|
|
85
|
+
// for nav.navigate (see ./qn.ts).
|
|
86
|
+
export function pageSizeOptionsFor(currentLimit) {
|
|
87
|
+
return PAGE_SIZE_OPTIONS.includes(currentLimit)
|
|
88
|
+
? PAGE_SIZE_OPTIONS
|
|
89
|
+
: [...PAGE_SIZE_OPTIONS, currentLimit].sort((a, b) => a - b);
|
|
90
|
+
}
|
|
91
|
+
export function useNavigateToCreateFor(schema, entityName, createScreen, createUnavailable) {
|
|
92
|
+
const nav = useNav();
|
|
93
|
+
const host = useReturnHost();
|
|
94
|
+
const editScreenId = useMemo(() => {
|
|
95
|
+
if (createScreen !== undefined)
|
|
96
|
+
return createScreen;
|
|
97
|
+
// The role may not open the declared create target; falling back to the
|
|
98
|
+
// entity's edit form would bypass the flow the app prescribes (e.g. a wizard).
|
|
99
|
+
if (createUnavailable === true)
|
|
100
|
+
return undefined;
|
|
101
|
+
// allowCreate:false = update-only edit screen (create runs through a
|
|
102
|
+
// lifecycle write), so it is not a "+ New" target. singleton:true opens
|
|
103
|
+
// the existing record without an entityId (EntityEditSingletonBody)
|
|
104
|
+
// instead of an empty create form, so it does not count for the same reason.
|
|
105
|
+
const edit = schema.screens.find((s) => s.type === "entityEdit" &&
|
|
106
|
+
s.entity === entityName &&
|
|
107
|
+
s.allowCreate !== false &&
|
|
108
|
+
s.singleton !== true);
|
|
109
|
+
return edit !== undefined ? lastSegment(edit.id) : undefined;
|
|
110
|
+
}, [schema.screens, entityName, createScreen, createUnavailable]);
|
|
111
|
+
const navigate = useCallback(() => {
|
|
112
|
+
if (editScreenId !== undefined)
|
|
113
|
+
navigateWithReturnTo(nav, { screenId: editScreenId }, host);
|
|
114
|
+
}, [nav, editScreenId, host]);
|
|
115
|
+
return editScreenId !== undefined ? navigate : undefined;
|
|
116
|
+
}
|
|
117
|
+
function formatSummaryValue(value, locale) {
|
|
118
|
+
if (isMoneyValue(value)) {
|
|
119
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency: value.currency }).format(value.amount);
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === "number")
|
|
122
|
+
return applyFormatSpec({ format: "number", locale }, value);
|
|
123
|
+
if (typeof value === "string" && ISO_DATE_PATTERN.test(value)) {
|
|
124
|
+
return applyFormatSpec({ format: "date", locale }, value);
|
|
125
|
+
}
|
|
126
|
+
return value === null || value === undefined ? "" : String(value);
|
|
127
|
+
}
|
|
128
|
+
export function formatSummaryParams(prefill, locale) {
|
|
129
|
+
return Object.fromEntries(Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]));
|
|
130
|
+
}
|
|
131
|
+
// Placeholders of `successMessage`: formatted submitted values, with a
|
|
132
|
+
// reference field resolved to the chosen record's label. A lookup that fails or
|
|
133
|
+
// misses the row (the lookup list is capped) falls back to the raw id.
|
|
134
|
+
export async function resolveSuccessMessageParams(screen, featureName, values, dispatcher, locale) {
|
|
135
|
+
const params = formatSummaryParams(values, locale);
|
|
136
|
+
await Promise.all(Object.entries(screen.fields).map(async ([name, def]) => {
|
|
137
|
+
const raw = values[name];
|
|
138
|
+
if (def.type !== "reference" || typeof raw !== "string" || raw === "")
|
|
139
|
+
return;
|
|
140
|
+
const target = parseRefTarget(def.entity, featureName);
|
|
141
|
+
const { queryQn, labelKey } = referenceLookupSource(target.featureName, target.entityName, def.labelField ?? "id");
|
|
142
|
+
const lookup = await dispatcher.query(queryQn, { limit: REFERENCE_LIST_LOOKUP_LIMIT });
|
|
143
|
+
if (!lookup.isSuccess)
|
|
144
|
+
return;
|
|
145
|
+
const row = lookup.data.rows.find((candidate) => candidate["id"] === raw);
|
|
146
|
+
const label = row?.[labelKey];
|
|
147
|
+
if (label !== undefined && label !== null)
|
|
148
|
+
params[name] = String(label);
|
|
149
|
+
}));
|
|
150
|
+
return params;
|
|
151
|
+
}
|
|
152
|
+
export function resolveActionFormSummary(summary, prefill, translate, locale) {
|
|
153
|
+
if (summary === undefined)
|
|
154
|
+
return undefined;
|
|
155
|
+
const params = formatSummaryParams(prefill, locale);
|
|
156
|
+
return {
|
|
157
|
+
title: translate(summary.title, params),
|
|
158
|
+
...(summary.subtitle !== undefined && { subtitle: translate(summary.subtitle, params) }),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// ---- entity-list ----
|
|
162
|
+
export function entityQueryCommand(featureName, entity, verb) {
|
|
163
|
+
return `${toKebab(featureName)}:query:${toKebab(entity)}:${verb}`;
|
|
164
|
+
}
|
|
165
|
+
// Payload for the server-side list query handler (LIST_PAYLOAD_SCHEMA):
|
|
166
|
+
// search/sort/sortDirection/limit + offset/totalCount for pager mode OR
|
|
167
|
+
// cursor for infinite scroll. Shared by EntityListBody and
|
|
168
|
+
// ProjectionListBody so the two branches can't drift on this shape
|
|
169
|
+
// (fw#2165) — entity-only additions (screen.filter, faceted filters) are
|
|
170
|
+
// layered on top by the caller instead of living in here.
|
|
171
|
+
export function buildListQueryPayload(state) {
|
|
172
|
+
const payload = { limit: state.limit };
|
|
173
|
+
if (state.search !== "")
|
|
174
|
+
payload["search"] = state.search;
|
|
175
|
+
if (state.sort !== null) {
|
|
176
|
+
payload["sort"] = state.sort.field;
|
|
177
|
+
payload["sortDirection"] = state.sort.dir;
|
|
178
|
+
}
|
|
179
|
+
if (state.usePager) {
|
|
180
|
+
// page=1 → offset=0, page=2 → offset=limit, etc. Server clamps itself
|
|
181
|
+
// when offset >= total.
|
|
182
|
+
const offset = (state.page - 1) * state.limit;
|
|
183
|
+
if (offset > 0)
|
|
184
|
+
payload["offset"] = offset;
|
|
185
|
+
// totalCount: extra COUNT(*) so the pager can render "Page X of Y".
|
|
186
|
+
payload["totalCount"] = true;
|
|
187
|
+
}
|
|
188
|
+
else if (state.useInfinite && state.cursor !== undefined) {
|
|
189
|
+
payload["cursor"] = state.cursor;
|
|
190
|
+
}
|
|
191
|
+
return payload;
|
|
192
|
+
}
|
|
193
|
+
export function redirectScreenTarget(redirect) {
|
|
194
|
+
return typeof redirect === "string" ? redirect : redirect.screen;
|
|
195
|
+
}
|
|
196
|
+
// Same-entity detection for navigate defaults: only an entityEdit screen of the
|
|
197
|
+
// SAME entity may receive the current record's id (it opens in UPDATE mode, which
|
|
198
|
+
// is what the boot validator assumes). Searched across all features.
|
|
199
|
+
export function isEntityEditScreenOfEntity(entity, targetScreen, schema, appFeatures) {
|
|
200
|
+
if (entity === undefined)
|
|
201
|
+
return false;
|
|
202
|
+
return [schema, ...appFeatures].some((feature) => feature.screens.some((s) => s.type === "entityEdit" && s.entity === entity && lastSegment(s.id) === targetScreen));
|
|
203
|
+
}
|
|
204
|
+
// Record-scoped targets (actionForm, secretMint) take the shown record's id; an
|
|
205
|
+
// entityEdit of ANOTHER entity must not, or it would open that entity's UPDATE
|
|
206
|
+
// mode on the wrong id instead of create.
|
|
207
|
+
export function isEntityEditScreenOfOtherEntity(entity, targetScreen, schema, appFeatures) {
|
|
208
|
+
return [schema, ...appFeatures].some((feature) => feature.screens.some((s) => s.type === "entityEdit" && s.entity !== entity && lastSegment(s.id) === targetScreen));
|
|
209
|
+
}
|
|
210
|
+
// A qualified redirect names its feature, so the search space is THAT feature
|
|
211
|
+
// only — the current schema's own screens must not shadow it with a same
|
|
212
|
+
// short-id screen of another type. An unqualified redirect searches this
|
|
213
|
+
// schema first (own screen wins), then every mounted feature. A named feature
|
|
214
|
+
// that isn't mounted (no provider / isolated test) falls back to this schema.
|
|
215
|
+
function findRedirectTargetScreen(redirect, schema, appFeatures) {
|
|
216
|
+
const redirectScreen = redirectScreenTarget(redirect);
|
|
217
|
+
const targetId = lastSegment(redirectScreen);
|
|
218
|
+
const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
|
|
219
|
+
const targetFeature = targetFeatureName !== undefined
|
|
220
|
+
? appFeatures.find((f) => f.featureName === targetFeatureName)
|
|
221
|
+
: undefined;
|
|
222
|
+
const searchSpace = targetFeature !== undefined
|
|
223
|
+
? targetFeature.screens
|
|
224
|
+
: targetFeatureName === undefined
|
|
225
|
+
? [...schema.screens, ...appFeatures.flatMap((f) => f.screens)]
|
|
226
|
+
: schema.screens;
|
|
227
|
+
return searchSpace.find((s) => lastSegment(s.id) === targetId);
|
|
228
|
+
}
|
|
229
|
+
// A redirect to a record screen ("show the result") is a deliberate forward
|
|
230
|
+
// navigation and keeps precedence over returnTo.
|
|
231
|
+
function redirectTargetsRecord(redirect, schema, appFeatures) {
|
|
232
|
+
const target = findRedirectTargetScreen(redirect, schema, appFeatures);
|
|
233
|
+
return (target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail"));
|
|
234
|
+
}
|
|
235
|
+
// Type-guard form narrows `returnTarget` for the caller's subsequent
|
|
236
|
+
// navigateToReturn(nav, returnTarget) call.
|
|
237
|
+
export function returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures) {
|
|
238
|
+
return returnTarget !== undefined && !redirectTargetsRecord(redirect, schema, appFeatures);
|
|
239
|
+
}
|
|
240
|
+
// Resolves an object-form redirect to a nav target — shared by
|
|
241
|
+
// actionForm and entityEdit (create + update) so the id
|
|
242
|
+
// carries over identically regardless of which screen type triggered it.
|
|
243
|
+
// `carriesId` gates entityId on the TARGET screen type: a redirect to a
|
|
244
|
+
// list screen never gets an id attached, matching actionForm's original
|
|
245
|
+
// behavior. The id itself prefers the write-handler's success payload
|
|
246
|
+
// (`submittedData`) and falls back to `fallbackRecord` — the entityEdit
|
|
247
|
+
// update path's already-loaded record, or the create path's just-submitted
|
|
248
|
+
// form values — both of which carry fields (e.g. a parent FK) the CRUD
|
|
249
|
+
// write executor's success payload doesn't flatly expose.
|
|
250
|
+
export function resolveRedirectTarget(redirect, submittedData, schema, appFeatures, fallbackRecord) {
|
|
251
|
+
const redirectScreen = redirectScreenTarget(redirect);
|
|
252
|
+
const idField = typeof redirect === "string" ? "id" : (redirect.idFrom ?? "id");
|
|
253
|
+
const targetId = lastSegment(redirectScreen);
|
|
254
|
+
const target = findRedirectTargetScreen(redirect, schema, appFeatures);
|
|
255
|
+
const carriesId = target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail");
|
|
256
|
+
if (!carriesId)
|
|
257
|
+
return { screenId: targetId, entityId: undefined, tab: undefined };
|
|
258
|
+
const entityId = extractIdField(submittedData, idField) ??
|
|
259
|
+
(fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
|
|
260
|
+
return {
|
|
261
|
+
screenId: targetId,
|
|
262
|
+
entityId,
|
|
263
|
+
tab: typeof redirect === "string" ? undefined : redirect.tab,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
export function navigateToRedirectTarget(nav, target) {
|
|
267
|
+
const navTarget = {
|
|
268
|
+
screenId: target.screenId,
|
|
269
|
+
...(target.entityId !== undefined && { entityId: target.entityId }),
|
|
270
|
+
};
|
|
271
|
+
if (target.tab === undefined)
|
|
272
|
+
nav.navigate(navTarget);
|
|
273
|
+
else
|
|
274
|
+
nav.navigate(navTarget, { searchParams: { tab: target.tab } });
|
|
275
|
+
}
|
|
276
|
+
// Links of the success box. Targets resolve like `redirect`; a link whose target
|
|
277
|
+
// needs a record id (entityEdit, projectionDetail) but whose payload field holds
|
|
278
|
+
// no string id is dropped instead of navigating to an id-less record screen.
|
|
279
|
+
// A link without idFrom to a non-record screen always navigates id-less.
|
|
280
|
+
export function resolveSuccessLinks(links, data, schema, appFeatures, nav, translate) {
|
|
281
|
+
const resolved = [];
|
|
282
|
+
(links ?? []).forEach((link, index) => {
|
|
283
|
+
const target = resolveRedirectTarget(link, data, schema, appFeatures);
|
|
284
|
+
if (target.entityId === undefined && redirectTargetsRecord(link, schema, appFeatures))
|
|
285
|
+
return;
|
|
286
|
+
const navTarget = {
|
|
287
|
+
screenId: target.screenId,
|
|
288
|
+
...(target.entityId !== undefined && { entityId: target.entityId }),
|
|
289
|
+
};
|
|
290
|
+
const options = target.tab === undefined ? undefined : { searchParams: { tab: target.tab } };
|
|
291
|
+
resolved.push({
|
|
292
|
+
key: String(index),
|
|
293
|
+
label: translate(link.label),
|
|
294
|
+
href: nav.hrefFor(navTarget, options),
|
|
295
|
+
onPress: () => nav.navigate(navTarget, options),
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
return resolved;
|
|
299
|
+
}
|
|
300
|
+
// Success navigation for a configured redirect — shared by entityEdit's
|
|
301
|
+
// save and writeHandler record actions. The string form navigates without an
|
|
302
|
+
// entityId; the object form resolves like actionForm's, with `record` as the
|
|
303
|
+
// fallback id source.
|
|
304
|
+
export function navigateAfterRedirect(options) {
|
|
305
|
+
const { nav, schema, appFeatures, returnTarget, redirect, resultData, record } = options;
|
|
306
|
+
if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
|
|
307
|
+
navigateToReturn(nav, returnTarget);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (typeof redirect === "string") {
|
|
311
|
+
nav.navigate({ screenId: lastSegment(redirect) });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
navigateToRedirectTarget(nav, resolveRedirectTarget(redirect, resultData, schema, appFeatures, record));
|
|
315
|
+
}
|
|
316
|
+
// The update payload nests the saved projection under `data`; a changed
|
|
317
|
+
// parent FK lives there, not in the pre-edit `record`.
|
|
318
|
+
export function overlayUpdatedProjection(record, resultData) {
|
|
319
|
+
if (typeof resultData !== "object" || resultData === null)
|
|
320
|
+
return record;
|
|
321
|
+
const projection = propertyOf(resultData, "data");
|
|
322
|
+
if (typeof projection !== "object" || projection === null)
|
|
323
|
+
return record;
|
|
324
|
+
return { ...record, ...projection };
|
|
325
|
+
}
|
|
@@ -2,10 +2,11 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
3
|
import { useCallback, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { RenderEdit } from "../components/render-edit.js";
|
|
5
|
+
import { rendererError } from "../dev-console.js";
|
|
5
6
|
import { useTranslation } from "../i18n.js";
|
|
6
7
|
import { usePrimitives } from "../primitives.js";
|
|
7
8
|
import { synthesizeActionFormEntity, synthesizeActionFormScreen, synthesizeSecretMintConfirmScreen, } from "./action-form-shim.js";
|
|
8
|
-
import { buildInitialValues, mergeSearchParamsIntoInitial, tenantCurrencyMoneyFieldNames, useMoneyCurrencyOverrides, } from "./
|
|
9
|
+
import { buildInitialValues, mergeSearchParamsIntoInitial, tenantCurrencyMoneyFieldNames, useMoneyCurrencyOverrides, } from "./entity-edit-prefill.js";
|
|
9
10
|
import { hiddenRequiredFieldsFilled, layoutFieldNames } from "./layout-fields.js";
|
|
10
11
|
import { useInitialValuesHandoff, useNav } from "./nav.js";
|
|
11
12
|
import { lastSegment } from "./qn.js";
|
|
@@ -104,8 +105,7 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
104
105
|
values[revealField.field] = value;
|
|
105
106
|
}
|
|
106
107
|
if (Object.values(values).every(isBlank)) {
|
|
107
|
-
|
|
108
|
-
console.error(`secretMint "${screen.id}": write result carried no value for reveal fields [${screen.reveal.fields
|
|
108
|
+
rendererError(`secretMint "${screen.id}": write result carried no value for reveal fields [${screen.reveal.fields
|
|
109
109
|
.map((revealField) => revealField.field)
|
|
110
110
|
.join(", ")}]`);
|
|
111
111
|
setRevealMissing(true);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PlatformComponent } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import type { EditExtensionSectionViewModel } from "../headless/index.ts";
|
|
4
|
+
import { type StickyPrimaryActionMarker } from "../primitives.tsx";
|
|
5
|
+
export declare function ExtensionSectionMount({ section, entityName: hostEntityName, entityId, initialValues, values, patch, validate, markSaved, hideTitle, actions, stepKey, onReportStepComplete, }: {
|
|
6
|
+
readonly section: EditExtensionSectionViewModel;
|
|
7
|
+
readonly entityName: string;
|
|
8
|
+
readonly entityId: string | null;
|
|
9
|
+
readonly initialValues?: Readonly<Record<string, unknown>>;
|
|
10
|
+
readonly values?: Readonly<Record<string, unknown>>;
|
|
11
|
+
readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
|
|
12
|
+
readonly validate?: () => boolean;
|
|
13
|
+
readonly markSaved?: () => void;
|
|
14
|
+
readonly hideTitle?: boolean;
|
|
15
|
+
/** section.actions, already resolved into buttons by the caller. */
|
|
16
|
+
readonly actions?: ReactNode;
|
|
17
|
+
readonly stepKey?: string;
|
|
18
|
+
readonly onReportStepComplete?: (stepKey: string, complete: boolean) => void;
|
|
19
|
+
}): ReactNode;
|
|
20
|
+
export declare function EditSlotMount({ slot, slotName, screenId, entityName, entityId, values, markSaved, hasUnsavedChanges, wizardStep, }: {
|
|
21
|
+
readonly slot: PlatformComponent;
|
|
22
|
+
readonly slotName: "header" | "titleAction" | "footer";
|
|
23
|
+
readonly screenId: string;
|
|
24
|
+
readonly entityName: string;
|
|
25
|
+
readonly entityId: string | null;
|
|
26
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
27
|
+
readonly markSaved?: () => void;
|
|
28
|
+
readonly hasUnsavedChanges?: boolean;
|
|
29
|
+
readonly wizardStep?: {
|
|
30
|
+
readonly index: number;
|
|
31
|
+
readonly isLast: boolean;
|
|
32
|
+
};
|
|
33
|
+
} & StickyPrimaryActionMarker): ReactNode;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect } from "react";
|
|
3
|
+
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
|
|
4
|
+
import { rendererWarn } from "../dev-console.js";
|
|
5
|
+
import { usePrimitives } from "../primitives.js";
|
|
6
|
+
// Resolves an extension-section's `{ react: { __component: "X" } }` marker
|
|
7
|
+
// to a registered React component via ExtensionSectionsProvider (filled in
|
|
8
|
+
// createKumikoApp from clientFeatures.extensionSectionComponents) and
|
|
9
|
+
// mounts it with the host entity name + id. Hook lives in its own
|
|
10
|
+
// component so we don't call `use*` inside vm.sections.map (rules-of-
|
|
11
|
+
// hooks would punish reordering sections between renders).
|
|
12
|
+
export function ExtensionSectionMount({ section, entityName: hostEntityName, entityId, initialValues, values, patch, validate, markSaved, hideTitle, actions, stepKey, onReportStepComplete, }) {
|
|
13
|
+
const { Banner, FillContainer, Section, Text } = usePrimitives();
|
|
14
|
+
const name = extensionSectionName(section.component);
|
|
15
|
+
const Component = useExtensionSectionComponent(name);
|
|
16
|
+
const reportStepComplete = useCallback((complete) => {
|
|
17
|
+
if (stepKey !== undefined)
|
|
18
|
+
onReportStepComplete?.(stepKey, complete);
|
|
19
|
+
}, [stepKey, onReportStepComplete]);
|
|
20
|
+
const testId = `section-extension-${section.title}`;
|
|
21
|
+
if (Component === undefined) {
|
|
22
|
+
const placeholder = (_jsx(Banner, { variant: "info", testId: `section-extension-placeholder-${section.title}`, children: _jsxs(Text, { children: ["Extension section component ", _jsx(Text, { variant: "code", children: name ?? "(no __component name)" }), " ", "not registered in clientFeatures.extensionSectionComponents."] }) }));
|
|
23
|
+
// Tabs mode already frames this in the caller's own Card — a Section
|
|
24
|
+
// here would add a second, unwanted divider inset (px-6 py-4) on top of
|
|
25
|
+
// the Card's own padding, misaligning this tab's content against the
|
|
26
|
+
// fields tab (Grid sits directly in the Card there). FillContainer keeps
|
|
27
|
+
// the testId without adding chrome (same primitive RelatedListSection
|
|
28
|
+
// uses for its own hideTitle branch).
|
|
29
|
+
return hideTitle === true ? (FillContainer !== undefined ? (_jsx(FillContainer, { testId: testId, children: placeholder })) : (placeholder)) : (_jsx(Section, { title: section.title, testId: testId, children: placeholder }, section.title));
|
|
30
|
+
}
|
|
31
|
+
const mounted = (_jsx(Component, { entityName: section.entityName ?? hostEntityName, entityId: entityId, initialValues: initialValues, values: values, patch: patch, validate: validate, markSaved: markSaved, ...(onReportStepComplete !== undefined && { reportStepComplete }) }));
|
|
32
|
+
// Same reasoning as the placeholder branch above: tabs mode's outer Card
|
|
33
|
+
// is the only frame this content gets, no nested Section divider.
|
|
34
|
+
return hideTitle === true ? (FillContainer !== undefined ? (_jsx(FillContainer, { testId: testId, children: mounted })) : (mounted)) : (_jsx(Section, { ...(actions !== undefined && { actions }), title: section.title, testId: testId, children: mounted }));
|
|
35
|
+
}
|
|
36
|
+
// Resolves an entityEdit slot's (`screen.slots.header` / `.footer`)
|
|
37
|
+
// `{ react: { __component: "X" } }` marker via the same ExtensionSectionsProvider
|
|
38
|
+
// registry as ExtensionSectionMount / ListHeaderSlotMount and mounts it. One
|
|
39
|
+
// component for both slots — header passes no hasUnsavedChanges/wizardStep,
|
|
40
|
+
// footer does. Own component for rules-of-hooks (useExtensionSectionComponent
|
|
41
|
+
// must not run conditionally inside RenderEdit's render body).
|
|
42
|
+
export function EditSlotMount({ slot, slotName, screenId, entityName, entityId, values, markSaved, hasUnsavedChanges, wizardStep, }) {
|
|
43
|
+
const name = extensionSectionName(slot);
|
|
44
|
+
const Component = useExtensionSectionComponent(name);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (name !== undefined && Component === undefined) {
|
|
47
|
+
rendererWarn(`[kumiko] Edit ${slotName} slot component "${name}" on screen "${screenId}" is not registered in clientFeatures.extensionSectionComponents — the ${slotName} slot renders nothing.`);
|
|
48
|
+
}
|
|
49
|
+
}, [name, Component, screenId, slotName]);
|
|
50
|
+
if (Component === undefined)
|
|
51
|
+
return null;
|
|
52
|
+
return (_jsx(Component, { entityName: entityName, entityId: entityId, values: values, ...(markSaved !== undefined && { markSaved }), ...(hasUnsavedChanges !== undefined && { hasUnsavedChanges }), ...(wizardStep !== undefined && { wizardStep }) }));
|
|
53
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ERROR_BOUNDARY_LOG_MARKER } from "@cosmicdrift/kumiko-types/error-boundary";
|
|
2
2
|
import { Component } from "react";
|
|
3
|
+
import { rendererError } from "../dev-console.js";
|
|
3
4
|
import { createErrorId } from "./error-id.js";
|
|
4
5
|
function toError(thrown) {
|
|
5
6
|
return thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
@@ -22,8 +23,7 @@ export class ErrorBoundary extends Component {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
logCaught(error, detail) {
|
|
25
|
-
|
|
26
|
-
console.error(`[kumiko] ${this.props.scope} ${ERROR_BOUNDARY_LOG_MARKER}:`, error, detail);
|
|
26
|
+
rendererError(`[kumiko] ${this.props.scope} ${ERROR_BOUNDARY_LOG_MARKER}:`, error, detail);
|
|
27
27
|
}
|
|
28
28
|
reset = () => {
|
|
29
29
|
this.setState({ caught: undefined });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
3
|
import { useMemo } from "react";
|
|
4
|
-
import { buildInitialValues, tenantCurrencyMoneyFieldNames, useMoneyCurrencyOverrides, } from "../app/
|
|
4
|
+
import { buildInitialValues, tenantCurrencyMoneyFieldNames, useMoneyCurrencyOverrides, } from "../app/entity-edit-prefill.js";
|
|
5
5
|
import { toKebab } from "../app/qn.js";
|
|
6
6
|
import { useTranslation } from "../i18n.js";
|
|
7
7
|
import { usePrimitives } from "../primitives.js";
|
|
@@ -13,6 +13,7 @@ import { findEditScreenFor } from "../app/screen-access.js";
|
|
|
13
13
|
import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
14
14
|
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
15
15
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
16
|
+
import { rendererWarn } from "../dev-console.js";
|
|
16
17
|
import { useQuery } from "../hooks/use-query.js";
|
|
17
18
|
import { useOptionalLocale, useTranslation } from "../i18n.js";
|
|
18
19
|
import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
|
|
@@ -46,8 +47,7 @@ function synthesizeRelatedListEntity(columns, sourceEntity) {
|
|
|
46
47
|
// onOpenDrawer wired is dropped (not rendered) with a dev warning, same as a
|
|
47
48
|
// drawer-kind rowAction/toolbarAction without one.
|
|
48
49
|
function warnRelatedListEmptyStateDrawerDropped(actionId) {
|
|
49
|
-
|
|
50
|
-
console.warn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
|
|
50
|
+
rendererWarn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
|
|
51
51
|
}
|
|
52
52
|
function buildRowGrouping(groupBy, translate, locale) {
|
|
53
53
|
return {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type * as z from "zod";
|
|
3
|
+
import type { EditSectionViewModel, FieldConditions, FormValues, Translate } from "../headless/index.ts";
|
|
4
|
+
import { relevantFieldIssues, zodErrorToFieldIssues } from "../headless/index.ts";
|
|
5
|
+
export declare function tabIdAt(section: EditSectionViewModel | undefined, index: number): string;
|
|
6
|
+
export declare function deriveFormFields<TValues extends FormValues, TCtx>(screen: EntityEditScreenDefinition): Record<string, FieldConditions<TValues, TCtx>>;
|
|
7
|
+
type FieldsSectionViewModel = Extract<EditSectionViewModel, {
|
|
8
|
+
readonly kind: "fields";
|
|
9
|
+
}>;
|
|
10
|
+
type DryRunSnapshot = {
|
|
11
|
+
readonly values: unknown;
|
|
12
|
+
readonly fields: Readonly<Record<string, {
|
|
13
|
+
readonly visible: boolean;
|
|
14
|
+
}>>;
|
|
15
|
+
};
|
|
16
|
+
type DryRunParse = {
|
|
17
|
+
readonly issues: ReturnType<typeof zodErrorToFieldIssues>;
|
|
18
|
+
readonly hiddenFields: ReadonlySet<string>;
|
|
19
|
+
};
|
|
20
|
+
export declare function dryRunParse(schema: z.ZodType, snapshot: DryRunSnapshot): DryRunParse | undefined;
|
|
21
|
+
export declare function dryRunFieldIssues(schema: z.ZodType, snapshot: DryRunSnapshot, scope: readonly string[] | undefined, includeRoot: boolean): ReturnType<typeof relevantFieldIssues>;
|
|
22
|
+
export declare function wizardStepKey(section: EditSectionViewModel, index: number): string;
|
|
23
|
+
export declare function isFieldsStepFilled(section: FieldsSectionViewModel, parse: DryRunParse | undefined): boolean;
|
|
24
|
+
export type FormErrorSource = "submit" | "delete";
|
|
25
|
+
export declare function formSectionDomId(sectionIndex: number): string;
|
|
26
|
+
/** A record title is a non-blank string; anything else leaves the screen title alone. */
|
|
27
|
+
export declare function resolveRecordTitle(value: unknown): string | undefined;
|
|
28
|
+
export declare function resolveTitleTemplate(template: string | undefined, values: FormValues, translate: Translate, fallback: string): string;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { evalFieldCondition, isFieldsEditSection, normalizeEditField, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { relevantFieldIssues, zodErrorToFieldIssues } from "../headless/index.js";
|
|
3
|
+
// End-to-end renderer for an entityEdit screen. Renders exclusively
|
|
4
|
+
// through primitives, no raw HTML. A native renderer that fills the same
|
|
5
|
+
// primitives registry gets the form without further changes.
|
|
6
|
+
function toConditionValue(cond) {
|
|
7
|
+
if (typeof cond === "boolean")
|
|
8
|
+
return cond;
|
|
9
|
+
// @cast-boundary form-values: TValues is structurally a record.
|
|
10
|
+
return (values) => evalFieldCondition(cond, values);
|
|
11
|
+
}
|
|
12
|
+
// Tab-strip key. The boot-validator makes `id` mandatory on a tabs layout and
|
|
13
|
+
// entityEdit only ever has "fields"/"extension" sections (relatedList and
|
|
14
|
+
// writeForm are rejected outright), so the positional fallback is unreachable
|
|
15
|
+
// for a tabs screen — it only keeps this total for other layouts.
|
|
16
|
+
export function tabIdAt(section, index) {
|
|
17
|
+
const declared = section?.kind === "fields" || section?.kind === "extension" ? section.id : undefined;
|
|
18
|
+
return declared ?? `section-${index}`;
|
|
19
|
+
}
|
|
20
|
+
export function deriveFormFields(screen) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const section of screen.layout.sections) {
|
|
23
|
+
// relatedList carries no `fields` for the form-condition map either.
|
|
24
|
+
if (!isFieldsEditSection(section))
|
|
25
|
+
continue;
|
|
26
|
+
for (const spec of sectionFieldSpecs(section)) {
|
|
27
|
+
const normalized = normalizeEditField(spec);
|
|
28
|
+
out[normalized.field] = {
|
|
29
|
+
...(normalized.visible !== undefined && {
|
|
30
|
+
visible: toConditionValue(normalized.visible),
|
|
31
|
+
}),
|
|
32
|
+
...(normalized.readOnly !== undefined && {
|
|
33
|
+
readonly: toConditionValue(normalized.readOnly),
|
|
34
|
+
}),
|
|
35
|
+
...(normalized.required !== undefined && {
|
|
36
|
+
required: toConditionValue(normalized.required),
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
// Dry-run parse against `schema` — NOT controller.validate(), which writes
|
|
44
|
+
// field errors into the snapshot. Undefined when the values parse cleanly.
|
|
45
|
+
// Parse once, then narrow per scope with scopedDryRunIssues: the wizard step
|
|
46
|
+
// check would otherwise re-parse the whole form per section on every keystroke.
|
|
47
|
+
export function dryRunParse(schema, snapshot) {
|
|
48
|
+
const parsed = schema.safeParse(snapshot.values);
|
|
49
|
+
if (parsed.success)
|
|
50
|
+
return undefined;
|
|
51
|
+
const hiddenFields = new Set(Object.entries(snapshot.fields)
|
|
52
|
+
.filter(([, state]) => !state.visible)
|
|
53
|
+
.map(([fieldKey]) => fieldKey));
|
|
54
|
+
return { issues: zodErrorToFieldIssues(parsed.error), hiddenFields };
|
|
55
|
+
}
|
|
56
|
+
// Issues are filtered exactly like the controller's validate(): hidden
|
|
57
|
+
// fields never count, `scope` narrows.
|
|
58
|
+
function scopedDryRunIssues(parse, scope, includeRoot) {
|
|
59
|
+
if (parse === undefined)
|
|
60
|
+
return [];
|
|
61
|
+
return relevantFieldIssues(parse.issues, {
|
|
62
|
+
hiddenFields: parse.hiddenFields,
|
|
63
|
+
...(scope !== undefined && { scope }),
|
|
64
|
+
includeRoot,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export function dryRunFieldIssues(schema, snapshot, scope, includeRoot) {
|
|
68
|
+
return scopedDryRunIssues(dryRunParse(schema, snapshot), scope, includeRoot);
|
|
69
|
+
}
|
|
70
|
+
// Index-independent: indices shift when a fields section hides.
|
|
71
|
+
export function wizardStepKey(section, index) {
|
|
72
|
+
const declaredId = section.kind === "fields" || section.kind === "extension" ? section.id : undefined;
|
|
73
|
+
return declaredId ?? section.title ?? `step-${index}`;
|
|
74
|
+
}
|
|
75
|
+
function isMoneyValue(value) {
|
|
76
|
+
return typeof value === "object" && value !== null && "amount" in value;
|
|
77
|
+
}
|
|
78
|
+
// The seeds buildInitialValues writes for NULL columns of an existing record.
|
|
79
|
+
function isEmptyFormValue(value) {
|
|
80
|
+
if (value === null || value === undefined || value === "" || value === 0 || value === false) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(value))
|
|
84
|
+
return value.length === 0;
|
|
85
|
+
return isMoneyValue(value) && value.amount === 0;
|
|
86
|
+
}
|
|
87
|
+
// select/boolean are excluded from the "holds user data" check: they are
|
|
88
|
+
// often pre-filled (defaults, server-set values), so they say nothing about
|
|
89
|
+
// whether the step was filled in. They still count through the validation.
|
|
90
|
+
export function isFieldsStepFilled(section, parse) {
|
|
91
|
+
const fieldNames = section.fields.map((f) => f.field);
|
|
92
|
+
if (scopedDryRunIssues(parse, fieldNames, false).length > 0)
|
|
93
|
+
return false;
|
|
94
|
+
return section.fields.some((f) => f.visible &&
|
|
95
|
+
!f.readOnly &&
|
|
96
|
+
f.type !== "boolean" &&
|
|
97
|
+
f.type !== "select" &&
|
|
98
|
+
!isEmptyFormValue(f.value));
|
|
99
|
+
}
|
|
100
|
+
export function formSectionDomId(sectionIndex) {
|
|
101
|
+
return `form-section-${sectionIndex}`;
|
|
102
|
+
}
|
|
103
|
+
/** A record title is a non-blank string; anything else leaves the screen title alone. */
|
|
104
|
+
export function resolveRecordTitle(value) {
|
|
105
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
106
|
+
}
|
|
107
|
+
const TITLE_PLACEHOLDER_PATTERN = /\{(\w+)\}/g;
|
|
108
|
+
// Falls back to the plain title while any placeholder value is still empty,
|
|
109
|
+
// so the header never shows "Add " mid-typing.
|
|
110
|
+
export function resolveTitleTemplate(template, values, translate, fallback) {
|
|
111
|
+
if (template === undefined)
|
|
112
|
+
return fallback;
|
|
113
|
+
const placeholders = [...translate(template).matchAll(TITLE_PLACEHOLDER_PATTERN)].map((match) => match[1] ?? "");
|
|
114
|
+
const isFilled = (name) => {
|
|
115
|
+
const value = values[name];
|
|
116
|
+
return value !== undefined && value !== null && String(value).trim() !== "";
|
|
117
|
+
};
|
|
118
|
+
if (!placeholders.every(isFilled))
|
|
119
|
+
return fallback;
|
|
120
|
+
return translate(template, values);
|
|
121
|
+
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
1
2
|
import type { FormValues } from "../headless/index.ts";
|
|
2
3
|
import type { RenderEditProps } from "./render-edit-types.ts";
|
|
3
4
|
export type { RenderEditAction, RenderEditChangeState, RenderEditControls, RenderEditProps, } from "./render-edit-types.ts";
|
|
4
|
-
import { type ReactNode } from "react";
|
|
5
|
-
export declare const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
|
|
6
|
-
/** A record title is a non-blank string; anything else leaves the screen title alone. */
|
|
7
|
-
export declare function resolveRecordTitle(value: unknown): string | undefined;
|
|
8
5
|
export declare function RenderEdit<TValues extends FormValues, TCtx = unknown>(props: RenderEditProps<TValues, TCtx>): ReactNode;
|