@cosmicdrift/kumiko-renderer 0.384.0 → 0.386.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 +29 -6
- 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.d.ts +1 -1
- 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";
|
|
@@ -6,6 +6,7 @@ import { useClientActionRuntime } from "../app/client-actions.js";
|
|
|
6
6
|
import { ErrorReference } from "../app/error-reference.js";
|
|
7
7
|
import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
|
|
8
8
|
import { useNav } from "../app/nav.js";
|
|
9
|
+
import { SYNTHESIZED_FIELD_PII_REASON } from "../app/projection-list-shim.js";
|
|
9
10
|
import { ReferenceFacetBridges, } from "../app/reference-facet-bridge.js";
|
|
10
11
|
import { navigateWithReturnTo, useReturnHost } from "../app/return-to.js";
|
|
11
12
|
import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, findRowClickAction, runDrawerRowAction, runProjectionRowNavigate, } from "../app/row-actions.js";
|
|
@@ -13,6 +14,7 @@ import { findEditScreenFor } from "../app/screen-access.js";
|
|
|
13
14
|
import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
14
15
|
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
15
16
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
17
|
+
import { rendererWarn } from "../dev-console.js";
|
|
16
18
|
import { useQuery } from "../hooks/use-query.js";
|
|
17
19
|
import { useOptionalLocale, useTranslation } from "../i18n.js";
|
|
18
20
|
import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
|
|
@@ -20,6 +22,9 @@ import { usePrimitives, } from "../primitives.js";
|
|
|
20
22
|
import { sortByAccessor } from "../sort-by-accessor.js";
|
|
21
23
|
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
22
24
|
import { RenderList } from "./render-list.js";
|
|
25
|
+
function isSortableCapable(field) {
|
|
26
|
+
return "sortable" in field;
|
|
27
|
+
}
|
|
23
28
|
const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
|
|
24
29
|
// Minimal EntityDefinition from the section's own columns — same shape as
|
|
25
30
|
// projection-list-shim's synthesizeProjectionEntity, but sortable is read
|
|
@@ -35,19 +40,37 @@ function synthesizeRelatedListEntity(columns, sourceEntity) {
|
|
|
35
40
|
const sortable = normalized.sortable === true;
|
|
36
41
|
const sourceField = sourceEntity?.fields[normalized.field];
|
|
37
42
|
fields[normalized.field] =
|
|
38
|
-
sourceField
|
|
39
|
-
?
|
|
40
|
-
:
|
|
43
|
+
sourceField === undefined
|
|
44
|
+
? valueTypeField(normalized.valueType, sortable)
|
|
45
|
+
: isSortableCapable(sourceField)
|
|
46
|
+
? Object.assign({}, sourceField, { sortable })
|
|
47
|
+
: sourceField;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
fields,
|
|
51
|
+
...(sourceEntity?.derivedFields !== undefined && { derivedFields: sourceEntity.derivedFields }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function valueTypeField(valueType, sortable) {
|
|
55
|
+
switch (valueType) {
|
|
56
|
+
case "number":
|
|
57
|
+
// decimal renders through the same number formatter; digits come from the column spec, not scale
|
|
58
|
+
case "decimal":
|
|
59
|
+
return { type: "number", sortable };
|
|
60
|
+
case "bigInt":
|
|
61
|
+
return { type: "bigInt", sortable };
|
|
62
|
+
case "money":
|
|
63
|
+
return { type: "money", sortable };
|
|
64
|
+
case undefined:
|
|
65
|
+
return { type: "text", allowPlaintext: SYNTHESIZED_FIELD_PII_REASON, sortable };
|
|
41
66
|
}
|
|
42
|
-
return { ...sourceEntity, fields };
|
|
43
67
|
}
|
|
44
68
|
// Mirrors row-actions.ts's own warnDrawerActionDropped (module-private
|
|
45
69
|
// there, so not reusable directly) — a drawer-kind emptyState.action with no
|
|
46
70
|
// onOpenDrawer wired is dropped (not rendered) with a dev warning, same as a
|
|
47
71
|
// drawer-kind rowAction/toolbarAction without one.
|
|
48
72
|
function warnRelatedListEmptyStateDrawerDropped(actionId) {
|
|
49
|
-
|
|
50
|
-
console.warn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
|
|
73
|
+
rendererWarn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
|
|
51
74
|
}
|
|
52
75
|
function buildRowGrouping(groupBy, translate, locale) {
|
|
53
76
|
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 {};
|