@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,12 @@
1
+ import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ReactNode } from "react";
3
+ import type { Translate } from "../headless/index.ts";
4
+ import type { FeatureSchema } from "./feature-schema.ts";
5
+ export declare function ProjectionDetailBody({ schema, screen, translate, entityId, }: {
6
+ readonly schema: FeatureSchema;
7
+ readonly screen: ProjectionDetailScreenDefinition & {
8
+ readonly detailFor?: string;
9
+ };
10
+ readonly translate?: Translate;
11
+ readonly entityId?: string;
12
+ }): ReactNode;
@@ -0,0 +1,464 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isWriteFormEditSection } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
4
+ import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
5
+ import { RenderEdit } from "../components/render-edit.js";
6
+ import { resolveRecordTitle } from "../components/render-edit-helpers.js";
7
+ import { useOptionalDispatcher } from "../context/dispatcher-context.js";
8
+ import { useUserRoles } from "../context/user-roles-context.js";
9
+ import { rendererWarn } from "../dev-console.js";
10
+ import { isSafeHref } from "../headless/index.js";
11
+ import { useQuery } from "../hooks/use-query.js";
12
+ import { useTranslation } from "../i18n.js";
13
+ import { requestLeave } from "../leave-guard.js";
14
+ import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
15
+ import { statusToneForOptionTone, statusToneForValue, usePrimitives, } from "../primitives.js";
16
+ import { screenFillsHeight } from "../screen-fills-height.js";
17
+ import { DrawerHost, useDrawerAction } from "./action-form-body.js";
18
+ import { useAppFeatures } from "./app-features-context.js";
19
+ import { useClientActionRuntime } from "./client-actions.js";
20
+ import { ErrorReference } from "./error-reference.js";
21
+ import { HeaderActionsBar } from "./header-actions-bar.js";
22
+ import { useNav } from "./nav.js";
23
+ import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
24
+ import { lastSegment } from "./qn.js";
25
+ import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
26
+ import { navigateToReturnOr, navigateWithReturnTo, useReturnHost, useReturnTarget, } from "./return-to.js";
27
+ import { buildRecordActions, refetchAfterWrite, runProjectionRowNavigate } from "./row-actions.js";
28
+ import { findEditScreenFor, navigateTargetAllows } from "./screen-access.js";
29
+ import { findEntityListScreen, navigateAfterRedirect, RecordNotFoundState, } from "./screen-shared.js";
30
+ const HEADER_CARD_DEFAULT_WIDTH = "4xl";
31
+ // Reuses runProjectionRowNavigate (row-actions.ts) instead of re-deriving
32
+ // navigate execution — a metric click has the same entity/screen/params
33
+ // shape as a RowActionNavigate, minus the fields (id, label, style) that
34
+ // function never reads.
35
+ function runMetricNavigate(nav, navigate, record, host) {
36
+ // tab alone (no screen/entity) stays on the current record and just
37
+ // activates that tab — no route change, so runProjectionRowNavigate
38
+ // (which always navigates) doesn't apply here.
39
+ if (navigate.screen === undefined && navigate.entity === undefined) {
40
+ const { tab } = navigate;
41
+ if (tab !== undefined)
42
+ requestLeave(() => nav.setSearchParams({ tab }));
43
+ return;
44
+ }
45
+ const base = { kind: "navigate", id: "metric-navigate", label: "" };
46
+ const action = navigate.entity !== undefined
47
+ ? {
48
+ ...base,
49
+ entity: navigate.entity,
50
+ ...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
51
+ ...(navigate.params !== undefined && { params: navigate.params }),
52
+ ...(navigate.tab !== undefined && { tab: navigate.tab }),
53
+ }
54
+ : navigate.screen !== undefined
55
+ ? {
56
+ ...base,
57
+ screen: navigate.screen,
58
+ ...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
59
+ ...(navigate.params !== undefined && { params: navigate.params }),
60
+ ...(navigate.tab !== undefined && { tab: navigate.tab }),
61
+ }
62
+ : undefined;
63
+ if (action === undefined)
64
+ return;
65
+ runProjectionRowNavigate(nav, action, { id: "", values: record }, host);
66
+ }
67
+ // Absolute http(s) check for RecordHeaderSpec.subtitleHref — deliberately
68
+ // stricter than isSafeHref (which also allows relative paths and mailto:),
69
+ // since a relative/mailto value here should render as plain text, not a
70
+ // "_blank" external link.
71
+ function isAbsoluteHttpUrl(value) {
72
+ return /^https?:\/\//i.test(value) && isSafeHref(value);
73
+ }
74
+ // hrefFor throws for an entity without a detailFor screen; outside
75
+ // createKumikoApp the feature list is empty, so navigateTargetAllows can't
76
+ // rule that out beforehand. The NavApi owns target resolution, so asking it
77
+ // and falling back to plain text covers both cases.
78
+ function hrefForNavigate(nav, navigate, record) {
79
+ const entityId = navigate.entityId !== undefined ? String(record[navigate.entityId] ?? "") : undefined;
80
+ try {
81
+ if (navigate.entity !== undefined) {
82
+ return entityId === undefined || entityId === ""
83
+ ? undefined
84
+ : nav.hrefFor({ entity: navigate.entity, id: entityId });
85
+ }
86
+ if (navigate.screen !== undefined) {
87
+ return nav.hrefFor({
88
+ screenId: navigate.screen,
89
+ ...(entityId !== undefined && entityId !== "" && { entityId }),
90
+ });
91
+ }
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ return undefined;
97
+ }
98
+ function buildSubtitleParts(parts, record, nav, appFeatures, userRoles, host) {
99
+ return parts.flatMap((part) => {
100
+ const field = typeof part === "string" ? part : part.field;
101
+ const text = String(record[field] ?? "");
102
+ if (text === "")
103
+ return [];
104
+ const navigate = typeof part === "string" ? undefined : part.navigate;
105
+ if (navigate === undefined || !navigateTargetAllows(navigate, appFeatures, userRoles)) {
106
+ return [{ field, text }];
107
+ }
108
+ const href = hrefForNavigate(nav, navigate, record);
109
+ if (href === undefined)
110
+ return [{ field, text }];
111
+ return [{ field, text, href, onPress: () => runMetricNavigate(nav, navigate, record, host) }];
112
+ });
113
+ }
114
+ function headerStatusTone(header, record) {
115
+ if (header.status === undefined)
116
+ return undefined;
117
+ const value = String(record[header.statusToneField ?? header.status] ?? "");
118
+ const declared = header.statusTones !== undefined && Object.hasOwn(header.statusTones, value)
119
+ ? header.statusTones[value]
120
+ : undefined;
121
+ const declaredTone = declared !== undefined ? statusToneForOptionTone(declared) : undefined;
122
+ return declaredTone ?? statusToneForValue(value);
123
+ }
124
+ function resolveSubtitleHref(header, record) {
125
+ if (header?.subtitleHref === undefined)
126
+ return undefined;
127
+ const value = record[header.subtitleHref];
128
+ return typeof value === "string" && isAbsoluteHttpUrl(value) ? value : undefined;
129
+ }
130
+ // Projection detail body: read-only single-row inspector over an explicit
131
+ // query instead of an entity (see projection-detail-shim.ts for the debt
132
+ // doc). Fetches itself via `screen.query` + `idParam` (like
133
+ // EntityEditUpdateBody, which derives the query QN from the entity
134
+ // convention; here the QN is author-explicit, as with ProjectionListBody).
135
+ // Renders through RenderEdit WITH a no-op customSubmit instead of a
136
+ // writeCommand: hasEditableSection() hides the save button (every field is
137
+ // readOnly), and even a native form submit (Enter keypress) would throw
138
+ // against controller.submit() without a submit config if there were no
139
+ // customSubmit; the no-op makes that path harmless instead of leaving it to
140
+ // chance.
141
+ export function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
142
+ const { Banner, Button, Dialog, Text, Grid, GridCell, Card, Tabs, StatusBadge, Metric, MetricBand, PageHeader, Link, ActionOverflowMenu, } = usePrimitives();
143
+ const t = useTranslation();
144
+ const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
145
+ const pageHeaderCompactContext = usePageHeaderCompact();
146
+ const effectiveTranslate = translate ?? t;
147
+ const nav = useNav();
148
+ const idParam = screen.idParam ?? "id";
149
+ // Singleton screens ignore whatever id the path happens to carry — the
150
+ // server picks the row from session/context, so forwarding a path id
151
+ // (even a stray/spoofed one) into the query or into extension sections'
152
+ // entityId fallback (resolveExtensionEntityId in render-edit.tsx) would
153
+ // let a client-controlled value leak where none is meant to reach.
154
+ const effectiveEntityId = screen.singleton === true ? undefined : entityId;
155
+ const isTabsMode = screen.layout.mode === "tabs";
156
+ const activeSection = useMemo(() => {
157
+ if (!isTabsMode || Tabs === undefined)
158
+ return undefined;
159
+ const tabParam = nav.searchParams["tab"];
160
+ return (screen.layout.sections.find((section) => section.id === tabParam) ?? screen.layout.sections[0]);
161
+ }, [isTabsMode, Tabs, screen.layout.sections, nav.searchParams]);
162
+ const hasTabs = isTabsMode && Tabs !== undefined && activeSection !== undefined;
163
+ // Tabs is an optional Core-Primitive: without it the screen falls back to
164
+ // the stacked all-sections layout instead of silently truncating to section 1.
165
+ useEffect(() => {
166
+ if (isTabsMode && Tabs === undefined) {
167
+ rendererWarn(`[kumiko] screen "${screen.id}" uses layout.mode: "tabs", but no <Tabs> primitive is registered — the tab strip will not render. createKumikoApp() from kumiko-renderer-web wires it automatically.`);
168
+ }
169
+ }, [isTabsMode, Tabs, screen.id]);
170
+ const entity = useMemo(() => synthesizeProjectionDetailEntity(screen.layout), [screen.layout]);
171
+ const detailScreen = useMemo(() => {
172
+ const source = activeSection !== undefined
173
+ ? { ...screen, layout: { ...screen.layout, sections: [activeSection] } }
174
+ : screen;
175
+ return synthesizeProjectionDetailScreen(source);
176
+ }, [screen, activeSection]);
177
+ const detailQuery = useQuery(screen.query, effectiveEntityId !== undefined ? { [idParam]: effectiveEntityId } : {});
178
+ // A writeForm section's handler creates a new record (see EditWriteFormSection's
179
+ // doc) — both the outer record (header/metrics/other sections) and a sibling
180
+ // relatedList section's own independently-fetched rows must reflect it. A
181
+ // query refetch alone only refreshes `record` below; bumping this as
182
+ // <RenderEdit>'s refreshToken makes RelatedListSection's own useQuery
183
+ // re-run too (it has no `live` subscription of its own). Refetch instead of
184
+ // remount (a `key`) so unsaved input in the other writeForm sections and
185
+ // the related lists' local state survive.
186
+ const [reloadNonce, setReloadNonce] = useState(0);
187
+ const reloadDetail = useCallback(async () => {
188
+ await refetchAfterWrite(detailQuery.refetch);
189
+ setReloadNonce((n) => n + 1);
190
+ }, [detailQuery.refetch]);
191
+ // Header actions render directly via RenderEditActionButton (fw#2713,
192
+ // headerActions moved out of RenderEdit's footer) — this component owns
193
+ // the failure-surfacing state RenderEdit used to own for them.
194
+ const [actionError, setActionError] = useState(null);
195
+ // Default edit action (fw#2166): resolved cross-feature over ALL mounted
196
+ // features, not just this feature's own schema — detailFor itself is
197
+ // resolved cross-feature by the boot-validator (detail-screens.ts), and
198
+ // the motivating case is a projectionDetail whose query belongs to one
199
+ // feature while the entity's entityEdit screen lives in another. Unlike
200
+ // useNavigateToCreateFor this must NOT filter on allowCreate/singleton —
201
+ // those gate create-targets, we're resolving an update-target by a
202
+ // known id.
203
+ const appFeatures = useAppFeatures();
204
+ const userRoles = useUserRoles();
205
+ const dispatcher = useOptionalDispatcher();
206
+ const clientActions = useClientActionRuntime();
207
+ const host = useReturnHost();
208
+ const returnTargetParam = useReturnTarget(screen.id);
209
+ // A singleton has no record route to come back to — same as entityEdit.
210
+ const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
211
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
212
+ const handleRecordLeft = useCallback((action, resultData, deletedShownRecord) => {
213
+ const record = detailQuery.data ?? {};
214
+ // Never return onto the just-deleted record.
215
+ const target = deletedShownRecord && returnTarget?.entityId === effectiveEntityId
216
+ ? undefined
217
+ : returnTarget;
218
+ if (action.redirect !== undefined) {
219
+ navigateAfterRedirect({
220
+ nav,
221
+ schema,
222
+ appFeatures,
223
+ returnTarget: target,
224
+ redirect: action.redirect,
225
+ resultData,
226
+ record,
227
+ });
228
+ return;
229
+ }
230
+ navigateToReturnOr(nav, target, () => {
231
+ const listScreen = screen.listScreenId ??
232
+ (screen.detailFor !== undefined
233
+ ? findEntityListScreen(schema, screen.detailFor)?.id
234
+ : undefined);
235
+ if (listScreen !== undefined)
236
+ nav.navigate({ screenId: lastSegment(listScreen) });
237
+ else
238
+ void detailQuery.refetch();
239
+ });
240
+ }, [
241
+ detailQuery.data,
242
+ detailQuery.refetch,
243
+ nav,
244
+ schema,
245
+ appFeatures,
246
+ returnTarget,
247
+ effectiveEntityId,
248
+ screen.listScreenId,
249
+ screen.detailFor,
250
+ ]);
251
+ const editScreen = useMemo(() => {
252
+ const detailFor = screen.detailFor;
253
+ if (detailFor === undefined)
254
+ return undefined;
255
+ return findEditScreenFor(detailFor, appFeatures, userRoles);
256
+ }, [appFeatures, screen.detailFor, userRoles]);
257
+ const defaultEditAction = useMemo(() => {
258
+ if (editScreen === undefined)
259
+ return undefined;
260
+ // editScreen.id is registry-qualified ("feature:screen:contact-edit");
261
+ // nav.navigate expects the short form (see useNavigateToCreateFor above).
262
+ const targetScreenId = lastSegment(editScreen.id);
263
+ return {
264
+ id: "edit",
265
+ label: effectiveTranslate("kumiko.actions.edit"),
266
+ icon: resolveActionIcon("edit"),
267
+ onPress: () => {
268
+ const target = {
269
+ screenId: targetScreenId,
270
+ ...(effectiveEntityId !== undefined && { entityId: effectiveEntityId }),
271
+ };
272
+ navigateWithReturnTo(nav, target, host);
273
+ },
274
+ };
275
+ }, [editScreen, effectiveTranslate, nav, effectiveEntityId, host]);
276
+ // a navigate-screen action targeting an entityEdit screen for the
277
+ // SAME entity this projectionDetail shows (screen.detailFor) defaults its
278
+ // entityId to the shown record's own id — without this fallback the target
279
+ // opens an empty create-form instead of the shown record, silently.
280
+ // Searched cross-feature, consistent with editScreen above.
281
+ const sameEntityScreenId = useCallback((targetScreen) => {
282
+ const record = detailQuery.data ?? {};
283
+ const targetIsEntityEditSameEntity = screen.detailFor !== undefined &&
284
+ appFeatures.some((feature) => feature.screens.some((s) => s.type === "entityEdit" &&
285
+ s.entity === screen.detailFor &&
286
+ lastSegment(s.id) === targetScreen));
287
+ return targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
288
+ }, [detailQuery.data, screen.detailFor, appFeatures]);
289
+ const buildSectionActions = useCallback((actions) => buildRecordActions({
290
+ actions,
291
+ record: detailQuery.data ?? {},
292
+ translate: effectiveTranslate,
293
+ nav,
294
+ host,
295
+ dispatcher,
296
+ openDrawer,
297
+ onWriteSuccess: reloadDetail,
298
+ onRecordLeft: handleRecordLeft,
299
+ sameEntityScreenId,
300
+ clientActions,
301
+ }), [
302
+ detailQuery.data,
303
+ reloadDetail,
304
+ effectiveTranslate,
305
+ nav,
306
+ host,
307
+ dispatcher,
308
+ clientActions,
309
+ openDrawer,
310
+ handleRecordLeft,
311
+ sameEntityScreenId,
312
+ ]);
313
+ const headerActions = useMemo(() => {
314
+ const declaredHasEdit = screen.actions?.some((a) => a.id === "edit") === true;
315
+ const resolved = buildRecordActions({
316
+ actions: screen.actions ?? [],
317
+ record: detailQuery.data ?? {},
318
+ translate: effectiveTranslate,
319
+ nav,
320
+ host,
321
+ dispatcher,
322
+ openDrawer,
323
+ onWriteSuccess: reloadDetail,
324
+ onRecordLeft: handleRecordLeft,
325
+ sameEntityScreenId,
326
+ clientActions,
327
+ });
328
+ if (defaultEditAction === undefined || declaredHasEdit)
329
+ return resolved;
330
+ return [defaultEditAction, ...(resolved ?? [])];
331
+ }, [
332
+ screen.actions,
333
+ defaultEditAction,
334
+ effectiveTranslate,
335
+ nav,
336
+ host,
337
+ dispatcher,
338
+ clientActions,
339
+ detailQuery.data,
340
+ reloadDetail,
341
+ openDrawer,
342
+ handleRecordLeft,
343
+ sameEntityScreenId,
344
+ ]);
345
+ const hasHeader = screen.header !== undefined;
346
+ const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
347
+ // ?? [] rather than threading `headerActions !== undefined` through every
348
+ // use below — an empty array is a safe no-op for .map/.length.
349
+ const headerActionsList = headerActions ?? [];
350
+ const hasHeaderActions = headerActionsList.length > 0;
351
+ const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
352
+ // A header card (metric band, status, actions) needs more room than the
353
+ // 640px form column; screens with only a record title keep the narrow one.
354
+ const widensScreenFormColumn = hasHeaderCard &&
355
+ !hasTabs &&
356
+ screenFillsHeight(screen) &&
357
+ detailScreen.layout.width === undefined;
358
+ const renderedDetailScreen = useMemo(() => widensScreenFormColumn
359
+ ? { ...detailScreen, layout: { ...detailScreen.layout, width: HEADER_CARD_DEFAULT_WIDTH } }
360
+ : detailScreen, [detailScreen, widensScreenFormColumn]);
361
+ if (effectiveEntityId === undefined && screen.singleton !== true) {
362
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-projection-detail-missing-id", children: ["Screen ", _jsx(Text, { variant: "code", children: screen.id }), " (projectionDetail) needs a row id in the path \u2014 open it from a row action."] }));
363
+ }
364
+ if (detailQuery.loading && detailQuery.data === null) {
365
+ return _jsx(QueryLoadingBanner, {});
366
+ }
367
+ if (detailQuery.error && detailQuery.data === null) {
368
+ return (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch }));
369
+ }
370
+ // A failed refetch keeps the last good record on screen.
371
+ const refetchErrorBanner = detailQuery.error ? (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch })) : null;
372
+ const record = detailQuery.data;
373
+ if (!record) {
374
+ const backListScreen = screen.listScreenId ??
375
+ (screen.detailFor !== undefined
376
+ ? findEntityListScreen(schema, screen.detailFor)?.id
377
+ : undefined);
378
+ return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...((returnTarget !== undefined || backListScreen !== undefined) && {
379
+ onBack: () => navigateToReturnOr(nav, returnTarget, () => {
380
+ if (backListScreen !== undefined) {
381
+ nav.navigate({ screenId: lastSegment(backListScreen) });
382
+ }
383
+ }),
384
+ }) }));
385
+ }
386
+ // Grouped into the head Card alongside title/status/metrics (fw#2713):
387
+ // these are actions on the record the head shows, not on whichever tab is
388
+ // open, so they must stay in place across tab switches instead of
389
+ // trailing the active tab's content in the card footer.
390
+ const headerActionsContent = hasHeaderActions && (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, ...(screen.primaryAction !== undefined && { preferredPrimaryId: screen.primaryAction }) }) }));
391
+ // Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
392
+ // this shares the same page padding/width as the record's edit card below
393
+ // it, instead of sitting flush against the screen edge (fw record-screen
394
+ // header polish).
395
+ const recordTitle = screen.recordTitleField !== undefined
396
+ ? resolveRecordTitle(record[screen.recordTitleField])
397
+ : undefined;
398
+ const header = screen.header;
399
+ const subtitleHref = resolveSubtitleHref(header, record);
400
+ // Plain Text, not Heading — the Card title slot already renders as its own
401
+ // heading element (h3); nesting another heading (h1) inside it is invalid HTML.
402
+ const headerTitleSlot = header !== undefined ? (header.status !== undefined ? (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }), StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-status", children: String(record[header.status] ?? "") }))] })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }))) : undefined;
403
+ const subtitleParts = header !== undefined && typeof header.subtitle === "object"
404
+ ? buildSubtitleParts(header.subtitle, record, nav, appFeatures, userRoles, host)
405
+ : undefined;
406
+ const headerSubtitleSlot = subtitleParts !== undefined ? (subtitleParts.length > 0 ? (_jsx(Text, { testId: "kumiko-screen-projection-detail-subtitle", children: subtitleParts.map((part, index) => (_jsxs(Fragment, { children: [index > 0 && (_jsx(Text, { variant: "muted", decorative: true, children: " · " })), part.href !== undefined ? (_jsx(Link, { href: part.href, ...(part.onPress !== undefined && { onPress: part.onPress }), className: "underline", testId: `kumiko-screen-projection-detail-subtitle-${part.field}`, children: part.text })) : (_jsx(Text, { testId: `kumiko-screen-projection-detail-subtitle-${part.field}`, children: part.text }))] }, part.field))) })) : undefined) : typeof header?.subtitle === "string" ? (subtitleHref !== undefined ? (_jsx(Link, { href: subtitleHref, target: "_blank", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") })) : (_jsx(Text, { variant: "muted", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") }))) : undefined;
407
+ // slots.header shares the header card with the actions (one card, actions
408
+ // top right) instead of rendering as an unframed block above it.
409
+ const metricItems = screen.metrics?.map((metric) => {
410
+ const field = metric.field;
411
+ const label = effectiveTranslate(metric.label);
412
+ const value = String(record[field] ?? "");
413
+ const testId = `kumiko-screen-projection-detail-metric-${field}`;
414
+ const navigate = metric.navigate;
415
+ const onPress = navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
416
+ ? () => runMetricNavigate(nav, navigate, record, host)
417
+ : undefined;
418
+ return Metric !== undefined ? (_jsx(Metric, { label: label, value: value, testId: testId, ...(onPress !== undefined && { onPress }) }, field)) : (_jsxs(GridCell, { children: [_jsx(Text, { variant: "small", testId: `${testId}-label`, children: label }), _jsx(Text, { testId: `${testId}-value`, children: value })] }, field));
419
+ });
420
+ // Metrics are a dl band when the primitive exists; the old Grid stays as the fallback.
421
+ const metricsBlock = (subtitle, lead) => MetricBand !== undefined ? (_jsx(MetricBand, { testId: "kumiko-screen-projection-detail-metrics", subtitle: subtitle, ...(lead !== undefined && { lead }), children: metricItems })) : (_jsx(Grid, { columns: screen.metrics?.length ?? 1, testId: "kumiko-screen-projection-detail-metrics", children: metricItems }));
422
+ const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
423
+ const pageHeaderCompact = pageHeaderCompactContext && usesPageHeaderSlot;
424
+ // The phone shell header clips a long status mid-word, so on phones the badge
425
+ // sits in the body instead of the shell header slot.
426
+ const statusBadge = header?.status !== undefined && StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : undefined;
427
+ // Lives in the band's subtitle row so it shares the metrics' horizontal inset.
428
+ const bandSubtitle = pageHeaderCompact && statusBadge !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-status-row", children: statusBadge }), headerSubtitleSlot] })) : (headerSubtitleSlot);
429
+ const actionErrorBanner = actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] }));
430
+ const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(!pageHeaderCompact && statusBadge !== undefined && { status: statusBadge }), ...(hasHeaderActions && {
431
+ actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader, ...(screen.primaryAction !== undefined && {
432
+ preferredPrimaryId: screen.primaryAction,
433
+ }) }) })),
434
+ }) }), MetricBand === undefined && bandSubtitle, MetricBand === undefined && headerSlot, (hasMetrics || bandSubtitle !== undefined || headerSlot !== undefined) &&
435
+ (MetricBand !== undefined || hasMetrics) &&
436
+ metricsBlock(bandSubtitle, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
437
+ ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
438
+ ...(headerTitleSlot === undefined &&
439
+ recordTitle !== undefined && { title: recordTitle }),
440
+ ...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
441
+ ...(headerSlot !== undefined && { headerContent: headerSlot }),
442
+ ...(hasHeaderActions && { headerActions: headerActionsContent }),
443
+ }, children: [hasMetrics && metricsBlock(), actionErrorBanner] }))), hasTabs && activeSection !== undefined && (_jsx(Tabs, { testId: "kumiko-screen-projection-detail-tabs", items: screen.layout.sections.map((section) => {
444
+ const countField = isWriteFormEditSection(section) ? undefined : section.countField;
445
+ const countValue = countField !== undefined ? record[countField] : undefined;
446
+ const count = typeof countValue === "number" && Number.isFinite(countValue)
447
+ ? countValue
448
+ : undefined;
449
+ return {
450
+ id: section.id ?? "",
451
+ label: effectiveTranslate(section.title ?? section.id ?? ""),
452
+ ...(count !== undefined && { count }),
453
+ };
454
+ }), activeId: activeSection.id ?? "", onSelect: (id) => requestLeave(() => nav.setSearchParams({ tab: id })) }))] }));
455
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(RenderEdit, { refreshToken: reloadNonce, screen: renderedDetailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
456
+ hasTabs ||
457
+ screen.slots?.header !== undefined ||
458
+ screen.recordTitleField !== undefined) && {
459
+ headerRegion: renderHeaderContent,
460
+ }), buildSectionActions: buildSectionActions, valueDisplay: screen.valueDisplay ?? "text" }, entityId), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
461
+ closeDrawer();
462
+ void reloadDetail();
463
+ } })] }));
464
+ }
@@ -2,7 +2,7 @@ import type { EditLayout, EntityDefinition, EntityEditScreenDefinition, Projecti
2
2
  /** Minimale EntityDefinition aus den Layout-Feldern: jedes Feld ein Text-
3
3
  * Feld — computeEditViewModel liest nur `fields[<f>].type`, Text reicht für
4
4
  * eine reine Anzeige (kein Select/Number-spezifisches Rendering nötig). */
5
- export declare function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefinition;
5
+ export declare function synthesizeProjectionDetailEntity(layout: EditLayout): Pick<EntityDefinition, "fields">;
6
6
  /** Wandelt ein ProjectionDetailScreenDefinition in die EntityEditScreen-Shape
7
7
  * die RenderEdit erwartet — mit jedem Feld hart auf readOnly:true erzwungen. */
8
8
  export declare function synthesizeProjectionDetailScreen(screen: ProjectionDetailScreenDefinition): EntityEditScreenDefinition;
@@ -20,6 +20,7 @@
20
20
  // Type-Lies hier silent — dann ist Zeit für eine echte RenderProjectionDetail-
21
21
  // Komponente.
22
22
  import { isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY as PROJECTION_DETAIL_PSEUDO_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
23
+ import { SYNTHESIZED_FIELD_PII_REASON } from "./projection-list-shim.js";
23
24
  /** Minimale EntityDefinition aus den Layout-Feldern: jedes Feld ein Text-
24
25
  * Feld — computeEditViewModel liest nur `fields[<f>].type`, Text reicht für
25
26
  * eine reine Anzeige (kein Select/Number-spezifisches Rendering nötig). */
@@ -30,7 +31,10 @@ export function synthesizeProjectionDetailEntity(layout) {
30
31
  if (!isFieldsEditSection(section))
31
32
  continue;
32
33
  for (const spec of sectionFieldSpecs(section)) {
33
- fields[normalizeEditField(spec).field] = { type: "text" };
34
+ fields[normalizeEditField(spec).field] = {
35
+ type: "text",
36
+ allowPlaintext: SYNTHESIZED_FIELD_PII_REASON,
37
+ };
34
38
  }
35
39
  }
36
40
  return { fields };
@@ -0,0 +1,10 @@
1
+ import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ReactNode } from "react";
3
+ import type { ListRowViewModel, Translate } from "../headless/index.ts";
4
+ import type { FeatureSchema } from "./feature-schema.ts";
5
+ export declare function ProjectionListBody({ schema, screen, translate, onRowClick, }: {
6
+ readonly schema: FeatureSchema;
7
+ readonly screen: ProjectionListScreenDefinition;
8
+ readonly translate?: Translate;
9
+ readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
10
+ }): ReactNode;