@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,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] = {
|
|
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;
|