@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.
Files changed (97) hide show
  1. package/dist/app/action-form-body.d.ts +50 -0
  2. package/dist/app/action-form-body.js +275 -0
  3. package/dist/app/client-actions.js +2 -2
  4. package/dist/app/config-edit-body.d.ts +9 -0
  5. package/dist/app/config-edit-body.js +340 -0
  6. package/dist/app/entity-edit-prefill.d.ts +27 -0
  7. package/dist/app/entity-edit-prefill.js +377 -0
  8. package/dist/app/entity-edit-screen.d.ts +11 -0
  9. package/dist/app/entity-edit-screen.js +395 -0
  10. package/dist/app/entity-list-screen.d.ts +10 -0
  11. package/dist/app/entity-list-screen.js +547 -0
  12. package/dist/app/kumiko-screen.d.ts +1 -36
  13. package/dist/app/kumiko-screen.js +16 -2855
  14. package/dist/app/projection-detail-body.d.ts +12 -0
  15. package/dist/app/projection-detail-body.js +464 -0
  16. package/dist/app/projection-detail-shim.d.ts +1 -1
  17. package/dist/app/projection-detail-shim.js +5 -1
  18. package/dist/app/projection-list-body.d.ts +10 -0
  19. package/dist/app/projection-list-body.js +245 -0
  20. package/dist/app/projection-list-shim.d.ts +2 -1
  21. package/dist/app/projection-list-shim.js +4 -0
  22. package/dist/app/reference-facet-bridge.js +2 -2
  23. package/dist/app/row-actions.js +3 -4
  24. package/dist/app/screen-shared.d.ts +72 -0
  25. package/dist/app/screen-shared.js +325 -0
  26. package/dist/app/secret-mint-body.js +3 -3
  27. package/dist/components/edit-mounts.d.ts +33 -0
  28. package/dist/components/edit-mounts.js +53 -0
  29. package/dist/components/error-boundary.js +2 -2
  30. package/dist/components/reference-create-dialog.js +1 -1
  31. package/dist/components/related-list-section.js +2 -2
  32. package/dist/components/render-edit-helpers.d.ts +29 -0
  33. package/dist/components/render-edit-helpers.js +121 -0
  34. package/dist/components/render-edit.d.ts +1 -4
  35. package/dist/components/render-edit.js +41 -443
  36. package/dist/components/render-field.js +3 -4
  37. package/dist/components/render-list.js +2 -2
  38. package/dist/components/use-wizard-form-draft.d.ts +51 -0
  39. package/dist/components/use-wizard-form-draft.js +291 -0
  40. package/dist/dev-console.d.ts +3 -0
  41. package/dist/dev-console.js +15 -0
  42. package/dist/headless/contracts/primitives.js +14 -17
  43. package/dist/headless/format/index.js +3 -3
  44. package/dist/headless/view-model/edit.d.ts +1 -1
  45. package/dist/headless/view-model/edit.js +9 -16
  46. package/dist/headless/view-model/list.d.ts +1 -1
  47. package/dist/headless/view-model/list.js +3 -8
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +5 -5
  50. package/dist/live/error-mapping.js +3 -4
  51. package/dist/web/app/create-app.js +5 -8
  52. package/dist/web/app/dashboard-body.js +4 -599
  53. package/dist/web/app/dashboard-panels.d.ts +53 -0
  54. package/dist/web/app/dashboard-panels.js +373 -0
  55. package/dist/web/app/dashboard-shared.d.ts +90 -0
  56. package/dist/web/app/dashboard-shared.js +171 -0
  57. package/dist/web/app/dashboard-text.d.ts +6 -0
  58. package/dist/web/app/dashboard-text.js +65 -0
  59. package/dist/web/layout/nav-tree.js +3 -4
  60. package/dist/web/layout/target-resolver-stub.js +2 -2
  61. package/dist/web/primitives/banner.d.ts +4 -0
  62. package/dist/web/primitives/banner.js +28 -0
  63. package/dist/web/primitives/button.d.ts +7 -0
  64. package/dist/web/primitives/button.js +48 -0
  65. package/dist/web/primitives/card-styles.d.ts +8 -0
  66. package/dist/web/primitives/card-styles.js +18 -0
  67. package/dist/web/primitives/card.d.ts +4 -0
  68. package/dist/web/primitives/card.js +41 -0
  69. package/dist/web/primitives/contexts.d.ts +22 -0
  70. package/dist/web/primitives/contexts.js +50 -0
  71. package/dist/web/primitives/data-table-cell.d.ts +76 -0
  72. package/dist/web/primitives/data-table-cell.js +403 -0
  73. package/dist/web/primitives/data-table.d.ts +4 -0
  74. package/dist/web/primitives/data-table.js +279 -0
  75. package/dist/web/primitives/default-form.d.ts +4 -0
  76. package/dist/web/primitives/default-form.js +79 -0
  77. package/dist/web/primitives/facet-filter.d.ts +14 -0
  78. package/dist/web/primitives/facet-filter.js +39 -0
  79. package/dist/web/primitives/field.d.ts +63 -0
  80. package/dist/web/primitives/field.js +181 -0
  81. package/dist/web/primitives/form.d.ts +53 -0
  82. package/dist/web/primitives/form.js +252 -0
  83. package/dist/web/primitives/index.d.ts +7 -30
  84. package/dist/web/primitives/index.js +27 -2006
  85. package/dist/web/primitives/input.d.ts +4 -0
  86. package/dist/web/primitives/input.js +166 -0
  87. package/dist/web/primitives/layout-elements.d.ts +14 -0
  88. package/dist/web/primitives/layout-elements.js +194 -0
  89. package/dist/web/primitives/modal.js +1 -1
  90. package/dist/web/primitives/pager.d.ts +22 -0
  91. package/dist/web/primitives/pager.js +89 -0
  92. package/dist/web/primitives/row-actions.d.ts +13 -0
  93. package/dist/web/primitives/row-actions.js +178 -0
  94. package/dist/web/primitives/secret-reveal.d.ts +4 -0
  95. package/dist/web/primitives/secret-reveal.js +67 -0
  96. package/package.json +5 -5
  97. 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 "./kumiko-screen.js";
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
- // biome-ignore lint/suspicious/noConsole: the user-facing banner carries no detail; this names the screen and fields for the developer
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
- // biome-ignore lint/suspicious/noConsole: the client has no logger and the boundary must not swallow the error; the e2e fixture detects boundary failures via this exact console.error line
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/kumiko-screen.js";
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
- // biome-ignore lint/suspicious/noConsole: dev-time warning, same pattern as row-actions.ts
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;