@cosmicdrift/kumiko-renderer 0.269.2 → 0.273.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/package.json +4 -4
- package/src/app/__tests__/action-form-field-labels.test.tsx +92 -0
- package/src/app/__tests__/action-form-shim.test.ts +21 -0
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +12 -1
- package/src/app/__tests__/list-filter-facets.test.tsx +90 -2
- package/src/app/__tests__/projection-detail-actions.test.tsx +13 -1
- package/src/app/__tests__/return-to.test.ts +255 -0
- package/src/app/__tests__/row-default-edit-action.test.tsx +1 -1
- package/src/app/__tests__/screen-access-allows.test.ts +12 -2
- package/src/app/action-form-shim.ts +4 -2
- package/src/app/kumiko-screen.tsx +402 -276
- package/src/app/list-facets.ts +70 -27
- package/src/app/reference-facet-bridge.tsx +58 -0
- package/src/app/return-to.tsx +128 -0
- package/src/app/row-actions.ts +184 -25
- package/src/app/secret-mint-body.tsx +6 -4
- package/src/components/__tests__/related-list-section.test.tsx +611 -5
- package/src/components/related-list-section.tsx +100 -18
- package/src/components/render-edit.tsx +76 -33
- package/src/index.ts +9 -0
|
@@ -15,12 +15,16 @@ import { useAppFeatures } from "../app/app-features-context";
|
|
|
15
15
|
import {
|
|
16
16
|
buildFilterFacets,
|
|
17
17
|
buildFilterPayload,
|
|
18
|
+
mergeReferenceFacetOptions,
|
|
18
19
|
resolveProjectionFacetSpecs,
|
|
19
20
|
} from "../app/list-facets";
|
|
20
21
|
import { useNav } from "../app/nav";
|
|
22
|
+
import { ReferenceFacetBridges, type ReferenceFacetOption } from "../app/reference-facet-bridge";
|
|
23
|
+
import { useReturnHost } from "../app/return-to";
|
|
21
24
|
import {
|
|
22
25
|
buildDefaultEditRowAction,
|
|
23
26
|
buildProjectionRowActions,
|
|
27
|
+
buildProjectionToolbarActions,
|
|
24
28
|
runProjectionRowNavigate,
|
|
25
29
|
} from "../app/row-actions";
|
|
26
30
|
import { findEditScreenFor } from "../app/screen-access";
|
|
@@ -63,6 +67,7 @@ function synthesizeRelatedListEntity(
|
|
|
63
67
|
export function RelatedListSection({
|
|
64
68
|
section,
|
|
65
69
|
parentId,
|
|
70
|
+
record,
|
|
66
71
|
featureName,
|
|
67
72
|
translate,
|
|
68
73
|
hideTitle,
|
|
@@ -70,6 +75,10 @@ export function RelatedListSection({
|
|
|
70
75
|
}: {
|
|
71
76
|
readonly section: EditRelatedListSectionViewModel;
|
|
72
77
|
readonly parentId: string;
|
|
78
|
+
/** The enclosing projectionDetail's own record (the "Akte") — evaluates
|
|
79
|
+
* toolbarActions' `visible`/`params` (RelatedListToolbarAction), the same
|
|
80
|
+
* record header actions already use. */
|
|
81
|
+
readonly record: Readonly<Record<string, unknown>>;
|
|
73
82
|
readonly featureName: string;
|
|
74
83
|
readonly translate?: Translate;
|
|
75
84
|
readonly hideTitle?: boolean;
|
|
@@ -85,6 +94,7 @@ export function RelatedListSection({
|
|
|
85
94
|
const t = useTranslation();
|
|
86
95
|
const effectiveTranslate = translate ?? t;
|
|
87
96
|
const nav = useNav();
|
|
97
|
+
const host = useReturnHost();
|
|
88
98
|
const dispatcher = useOptionalDispatcher();
|
|
89
99
|
const appFeatures = useAppFeatures();
|
|
90
100
|
const userRoles = useUserRoles();
|
|
@@ -120,19 +130,46 @@ export function RelatedListSection({
|
|
|
120
130
|
const [filters, setFilters] = useState<Readonly<Record<string, readonly string[]>>>({});
|
|
121
131
|
|
|
122
132
|
const facetSpecs = useMemo(
|
|
123
|
-
() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate),
|
|
124
|
-
[section.facets, effectiveTranslate],
|
|
133
|
+
() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName),
|
|
134
|
+
[section.facets, effectiveTranslate, featureName],
|
|
135
|
+
);
|
|
136
|
+
const [referenceFacetOptions, setReferenceFacetOptions] = useState<
|
|
137
|
+
Record<string, readonly ReferenceFacetOption[]>
|
|
138
|
+
>({});
|
|
139
|
+
const handleFacetOptions = useCallback(
|
|
140
|
+
(field: string, options: readonly ReferenceFacetOption[]) =>
|
|
141
|
+
setReferenceFacetOptions((prev) =>
|
|
142
|
+
prev[field] === options ? prev : { ...prev, [field]: options },
|
|
143
|
+
),
|
|
144
|
+
[],
|
|
145
|
+
);
|
|
146
|
+
const resolvedFacetSpecs = useMemo(
|
|
147
|
+
() => mergeReferenceFacetOptions(facetSpecs, referenceFacetOptions),
|
|
148
|
+
[facetSpecs, referenceFacetOptions],
|
|
125
149
|
);
|
|
126
150
|
const filterPayload = useMemo(
|
|
127
151
|
() =>
|
|
128
|
-
buildFilterPayload(
|
|
129
|
-
|
|
152
|
+
buildFilterPayload(
|
|
153
|
+
filters,
|
|
154
|
+
(field) => resolvedFacetSpecs.find((spec) => spec.field === field)?.type,
|
|
155
|
+
),
|
|
156
|
+
[filters, resolvedFacetSpecs],
|
|
157
|
+
);
|
|
158
|
+
const filterFacets = useMemo<DataTableFacet[]>(
|
|
159
|
+
() => buildFilterFacets(resolvedFacetSpecs),
|
|
160
|
+
[resolvedFacetSpecs],
|
|
130
161
|
);
|
|
131
|
-
const filterFacets = useMemo<DataTableFacet[]>(() => buildFilterFacets(facetSpecs), [facetSpecs]);
|
|
132
162
|
|
|
133
163
|
const payload = useMemo(
|
|
134
164
|
() => ({
|
|
135
|
-
|
|
165
|
+
// `parentFilter` sends the parent id as a server-side `filter` clause
|
|
166
|
+
// instead of a bespoke top-level key — the generic `<entity>:list`
|
|
167
|
+
// query understands `filter`, not an arbitrary `parentParam` key. Kept
|
|
168
|
+
// separate from `filterPayload` (user facets) below so a facet
|
|
169
|
+
// selection can never clear or overwrite it.
|
|
170
|
+
...(section.parentFilter !== undefined
|
|
171
|
+
? { filter: { field: section.parentFilter.field, op: "eq" as const, value: parentId } }
|
|
172
|
+
: { [section.parentParam ?? "id"]: parentId }),
|
|
136
173
|
...(section.pageSize !== undefined && { limit: section.pageSize }),
|
|
137
174
|
// Gated on the declared capability, not just on state carrying a value —
|
|
138
175
|
// same rule as ProjectionListBody: a param the bound query's Zod schema
|
|
@@ -141,6 +178,7 @@ export function RelatedListSection({
|
|
|
141
178
|
...(section.facets !== undefined && filterPayload.length > 0 && { filters: filterPayload }),
|
|
142
179
|
}),
|
|
143
180
|
[
|
|
181
|
+
section.parentFilter,
|
|
144
182
|
section.parentParam,
|
|
145
183
|
section.pageSize,
|
|
146
184
|
section.searchable,
|
|
@@ -197,7 +235,7 @@ export function RelatedListSection({
|
|
|
197
235
|
nav.navigate({ entity: rowClick.entity, id });
|
|
198
236
|
}
|
|
199
237
|
: rowClickAction !== undefined
|
|
200
|
-
? (row: ListRowViewModel) => runProjectionRowNavigate(nav, rowClickAction, row)
|
|
238
|
+
? (row: ListRowViewModel) => runProjectionRowNavigate(nav, rowClickAction, row, host)
|
|
201
239
|
: undefined;
|
|
202
240
|
|
|
203
241
|
// Same execution path as projectionList's rowActions (kumiko-screen.tsx) —
|
|
@@ -214,6 +252,7 @@ export function RelatedListSection({
|
|
|
214
252
|
refetch: rowsQuery.refetch,
|
|
215
253
|
openDrawer: onOpenDrawer,
|
|
216
254
|
defaultEditRowAction,
|
|
255
|
+
host,
|
|
217
256
|
}),
|
|
218
257
|
[
|
|
219
258
|
section.rowActions,
|
|
@@ -223,6 +262,42 @@ export function RelatedListSection({
|
|
|
223
262
|
rowsQuery.refetch,
|
|
224
263
|
onOpenDrawer,
|
|
225
264
|
defaultEditRowAction,
|
|
265
|
+
host,
|
|
266
|
+
],
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Toolbar actions (e.g. "+ Create" above the tab table) — same schema
|
|
270
|
+
// and dispatch semantics as entityList/projectionList.
|
|
271
|
+
const toolbarActionButtons = useMemo(
|
|
272
|
+
() =>
|
|
273
|
+
buildProjectionToolbarActions({
|
|
274
|
+
toolbarActions: section.toolbarActions,
|
|
275
|
+
translate: effectiveTranslate,
|
|
276
|
+
dispatcher,
|
|
277
|
+
nav,
|
|
278
|
+
refetch: rowsQuery.refetch,
|
|
279
|
+
navigatePrefill:
|
|
280
|
+
section.parentFilter !== undefined
|
|
281
|
+
? { [section.parentFilter.field]: parentId }
|
|
282
|
+
: { [section.parentParam ?? "id"]: parentId },
|
|
283
|
+
record,
|
|
284
|
+
host,
|
|
285
|
+
...(onOpenDrawer !== undefined && {
|
|
286
|
+
openDrawer: (action) => onOpenDrawer(action, undefined),
|
|
287
|
+
}),
|
|
288
|
+
}),
|
|
289
|
+
[
|
|
290
|
+
section.toolbarActions,
|
|
291
|
+
section.parentParam,
|
|
292
|
+
section.parentFilter,
|
|
293
|
+
parentId,
|
|
294
|
+
record,
|
|
295
|
+
effectiveTranslate,
|
|
296
|
+
dispatcher,
|
|
297
|
+
nav,
|
|
298
|
+
host,
|
|
299
|
+
rowsQuery.refetch,
|
|
300
|
+
onOpenDrawer,
|
|
226
301
|
],
|
|
227
302
|
);
|
|
228
303
|
|
|
@@ -273,18 +348,15 @@ export function RelatedListSection({
|
|
|
273
348
|
})}
|
|
274
349
|
{...(onRowClick !== undefined && { onRowClick })}
|
|
275
350
|
{...(rowActions !== undefined && { rowActions })}
|
|
276
|
-
{...(
|
|
351
|
+
{...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons })}
|
|
352
|
+
{...(hideTitle === true && { scrollBody: true })}
|
|
277
353
|
/>
|
|
278
354
|
</>
|
|
279
355
|
);
|
|
280
356
|
|
|
281
357
|
// hideTitle (tabs mode) → the tab panel is already the boundary: no
|
|
282
|
-
// Section card wrapper here
|
|
283
|
-
//
|
|
284
|
-
// available height so a long Akte tab scrolls internally instead of
|
|
285
|
-
// stretching the page, while a short one still sizes to its content
|
|
286
|
-
// instead of stretching to the bottom (fw#2722, fw#2778) — the list sits
|
|
287
|
-
// directly in the tab. `FillContainer` is this section's
|
|
358
|
+
// Section card wrapper here — the table keeps its own frame to match the
|
|
359
|
+
// list-screen look; `scrollBody` caps it to the panel height. `FillContainer` is this section's
|
|
288
360
|
// link in RenderEdit's `fillHeight` chain (see render-edit.tsx): it is
|
|
289
361
|
// always this section's own root whenever hideTitle is set, since tabs
|
|
290
362
|
// mode narrows RenderEdit to exactly this one active section. A platform
|
|
@@ -294,13 +366,23 @@ export function RelatedListSection({
|
|
|
294
366
|
// Stacked (non-tabs) sections keep the card frame and document-flow
|
|
295
367
|
// height since they render a visible title and aren't confined to a tab
|
|
296
368
|
// panel.
|
|
369
|
+
const bridges = <ReferenceFacetBridges specs={facetSpecs} onOptions={handleFacetOptions} />;
|
|
370
|
+
|
|
297
371
|
if (hideTitle) {
|
|
298
|
-
return
|
|
372
|
+
return (
|
|
373
|
+
<>
|
|
374
|
+
{bridges}
|
|
375
|
+
{FillContainer !== undefined ? <FillContainer>{content}</FillContainer> : content}
|
|
376
|
+
</>
|
|
377
|
+
);
|
|
299
378
|
}
|
|
300
379
|
|
|
301
380
|
return (
|
|
302
|
-
|
|
303
|
-
{
|
|
304
|
-
|
|
381
|
+
<>
|
|
382
|
+
{bridges}
|
|
383
|
+
<Section title={section.title} testId={`related-list-${section.title}`}>
|
|
384
|
+
{content}
|
|
385
|
+
</Section>
|
|
386
|
+
</>
|
|
305
387
|
);
|
|
306
388
|
}
|
|
@@ -340,6 +340,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
340
340
|
const {
|
|
341
341
|
Button,
|
|
342
342
|
Banner,
|
|
343
|
+
Card,
|
|
343
344
|
Dialog,
|
|
344
345
|
Form,
|
|
345
346
|
Section,
|
|
@@ -1177,9 +1178,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1177
1178
|
return undefined;
|
|
1178
1179
|
};
|
|
1179
1180
|
const formTitle = resolveScreenText("title") ?? screen.id;
|
|
1181
|
+
// screen.description is head-card copy, not tab content — in tabs mode
|
|
1182
|
+
// (hideSectionTitles) the head card already carries title/subtitle/status.
|
|
1180
1183
|
const formSubtitle =
|
|
1181
1184
|
resolveScreenText("subtitle") ??
|
|
1182
|
-
(
|
|
1185
|
+
(hideSectionTitles !== true && screen.description !== undefined
|
|
1186
|
+
? translate(screen.description)
|
|
1187
|
+
: undefined);
|
|
1183
1188
|
|
|
1184
1189
|
return (
|
|
1185
1190
|
<ExtensionFormRegistryProvider value={extensionFormRegistry}>
|
|
@@ -1334,6 +1339,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1334
1339
|
key={section.title}
|
|
1335
1340
|
section={section}
|
|
1336
1341
|
parentId={parentId}
|
|
1342
|
+
// @cast-boundary form-values: TValues ist strukturell ein Record.
|
|
1343
|
+
record={snapshot.values as unknown as Readonly<Record<string, unknown>>}
|
|
1337
1344
|
featureName={featureName}
|
|
1338
1345
|
translate={translate}
|
|
1339
1346
|
hideTitle={hideSectionTitles}
|
|
@@ -1359,16 +1366,75 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1359
1366
|
);
|
|
1360
1367
|
}
|
|
1361
1368
|
if (!section.visible) return null;
|
|
1362
|
-
// Section-Header unterdrücken wenn er den Form-Titel der
|
|
1363
|
-
// Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
|
|
1364
|
-
// ActionForms, deren Section-Label = Screen-Titel ist).
|
|
1365
|
-
const sectionTitle = hideSectionTitles
|
|
1366
|
-
? undefined
|
|
1367
|
-
: section.title === formTitle
|
|
1368
|
-
? undefined
|
|
1369
|
-
: section.title;
|
|
1370
1369
|
// Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
|
|
1371
1370
|
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
1371
|
+
const renderFieldGrid = (
|
|
1372
|
+
fields: readonly EditFieldViewModel[],
|
|
1373
|
+
columns: number,
|
|
1374
|
+
gridKey: string,
|
|
1375
|
+
): ReactNode => (
|
|
1376
|
+
<Grid key={gridKey} columns={columns}>
|
|
1377
|
+
{fields.map((field: EditFieldViewModel) => (
|
|
1378
|
+
<GridCellForField
|
|
1379
|
+
key={field.field}
|
|
1380
|
+
field={disabled ? { ...field, readOnly: true } : field}
|
|
1381
|
+
columns={columns}
|
|
1382
|
+
issues={snapshot.errors[field.field]}
|
|
1383
|
+
onChange={(v) => {
|
|
1384
|
+
(controller.setField as (k: string, v: unknown) => void)(field.field, v);
|
|
1385
|
+
}}
|
|
1386
|
+
GridCell={GridCell}
|
|
1387
|
+
featureName={featureName}
|
|
1388
|
+
{...(labelAppendix !== undefined && {
|
|
1389
|
+
labelAppendix: labelAppendix(field.field),
|
|
1390
|
+
})}
|
|
1391
|
+
{...(fieldAppendix !== undefined && {
|
|
1392
|
+
fieldAppendix: fieldAppendix(field.field),
|
|
1393
|
+
})}
|
|
1394
|
+
allIssues={snapshot.errors}
|
|
1395
|
+
valueDisplay={valueDisplay}
|
|
1396
|
+
row={snapshot.values}
|
|
1397
|
+
/>
|
|
1398
|
+
))}
|
|
1399
|
+
</Grid>
|
|
1400
|
+
);
|
|
1401
|
+
// Tabs mode renders one or more titled cards, so this card's actual
|
|
1402
|
+
// heading doesn't visually duplicate the short Tab strip label.
|
|
1403
|
+
if (hideSectionTitles === true) {
|
|
1404
|
+
if (section.groups !== undefined) {
|
|
1405
|
+
const groupsEl = (
|
|
1406
|
+
<Grid key={sectionKey} columns={1}>
|
|
1407
|
+
{section.groups.map((group, groupIndex) => (
|
|
1408
|
+
<Card
|
|
1409
|
+
key={group.title}
|
|
1410
|
+
slots={{ title: group.title }}
|
|
1411
|
+
testId={`section-${sectionKey}-group-${groupIndex}`}
|
|
1412
|
+
>
|
|
1413
|
+
{renderFieldGrid(
|
|
1414
|
+
group.fields,
|
|
1415
|
+
group.columns,
|
|
1416
|
+
`${sectionKey}-group-${groupIndex}-grid`,
|
|
1417
|
+
)}
|
|
1418
|
+
</Card>
|
|
1419
|
+
))}
|
|
1420
|
+
</Grid>
|
|
1421
|
+
);
|
|
1422
|
+
return wrapWizardStep(sectionKey, groupsEl);
|
|
1423
|
+
}
|
|
1424
|
+
const cardEl = (
|
|
1425
|
+
<Card
|
|
1426
|
+
key={sectionKey}
|
|
1427
|
+
{...(section.title !== undefined && { slots: { title: section.title } })}
|
|
1428
|
+
testId={`section-${sectionKey}`}
|
|
1429
|
+
>
|
|
1430
|
+
{renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
|
|
1431
|
+
</Card>
|
|
1432
|
+
);
|
|
1433
|
+
return wrapWizardStep(sectionKey, cardEl);
|
|
1434
|
+
}
|
|
1435
|
+
// Suppress the section header when it would just repeat the form
|
|
1436
|
+
// title verbatim (typical for single-section actionForms).
|
|
1437
|
+
const sectionTitle = section.title === formTitle ? undefined : section.title;
|
|
1372
1438
|
const sectionEl = (
|
|
1373
1439
|
<Section
|
|
1374
1440
|
key={sectionKey}
|
|
@@ -1377,30 +1443,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1377
1443
|
{...(section.icon !== undefined && { icon: section.icon })}
|
|
1378
1444
|
testId={`section-${sectionKey}`}
|
|
1379
1445
|
>
|
|
1380
|
-
|
|
1381
|
-
{section.fields.map((field: EditFieldViewModel) => (
|
|
1382
|
-
<GridCellForField
|
|
1383
|
-
key={field.field}
|
|
1384
|
-
field={disabled ? { ...field, readOnly: true } : field}
|
|
1385
|
-
columns={section.columns}
|
|
1386
|
-
issues={snapshot.errors[field.field]}
|
|
1387
|
-
onChange={(v) => {
|
|
1388
|
-
(controller.setField as (k: string, v: unknown) => void)(field.field, v);
|
|
1389
|
-
}}
|
|
1390
|
-
GridCell={GridCell}
|
|
1391
|
-
featureName={featureName}
|
|
1392
|
-
{...(labelAppendix !== undefined && {
|
|
1393
|
-
labelAppendix: labelAppendix(field.field),
|
|
1394
|
-
})}
|
|
1395
|
-
{...(fieldAppendix !== undefined && {
|
|
1396
|
-
fieldAppendix: fieldAppendix(field.field),
|
|
1397
|
-
})}
|
|
1398
|
-
allIssues={snapshot.errors}
|
|
1399
|
-
valueDisplay={valueDisplay}
|
|
1400
|
-
row={snapshot.values}
|
|
1401
|
-
/>
|
|
1402
|
-
))}
|
|
1403
|
-
</Grid>
|
|
1446
|
+
{renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
|
|
1404
1447
|
</Section>
|
|
1405
1448
|
);
|
|
1406
1449
|
return wrapWizardStep(sectionKey, sectionEl);
|
package/src/index.ts
CHANGED
|
@@ -82,6 +82,15 @@ export {
|
|
|
82
82
|
useNavigateWithInitialValues,
|
|
83
83
|
} from "./app/nav";
|
|
84
84
|
export { lastSegment } from "./app/qn";
|
|
85
|
+
export type { ReturnHost } from "./app/return-to";
|
|
86
|
+
export {
|
|
87
|
+
navigateWithReturnTo,
|
|
88
|
+
RETURN_TO_PARAM,
|
|
89
|
+
ReturnHostProvider,
|
|
90
|
+
resolveReturnTarget,
|
|
91
|
+
useReturnHost,
|
|
92
|
+
useReturnTarget,
|
|
93
|
+
} from "./app/return-to";
|
|
85
94
|
export type { EmbeddedScreenTarget } from "./app/use-embedded-screen";
|
|
86
95
|
export { useEmbeddedScreen } from "./app/use-embedded-screen";
|
|
87
96
|
export type { VariableChipsProps } from "./app/variable-chips";
|