@cosmicdrift/kumiko-renderer 0.307.0 → 0.309.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 +5 -5
- package/src/app/form-schema.ts +1 -1
- package/src/app/kumiko-screen.tsx +91 -259
- package/src/app/row-actions.ts +215 -56
- package/src/changes.json +7 -0
- package/src/components/__tests__/related-list-section-empty-state.test.tsx +143 -0
- package/src/components/__tests__/related-list-section.test.tsx +2 -2
- package/src/components/__tests__/render-field-number-grouping.test.tsx +76 -0
- package/src/components/__tests__/write-form-section.test.tsx +54 -1
- package/src/components/related-list-section.tsx +94 -15
- package/src/components/render-edit-types.ts +13 -2
- package/src/components/render-edit.tsx +147 -36
- package/src/components/render-field.tsx +3 -1
- package/src/components/render-list.tsx +6 -4
- package/src/components/write-form-section.tsx +37 -4
- package/src/primitives.tsx +11 -3
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
buildDefaultEditRowAction,
|
|
26
26
|
buildProjectionRowActions,
|
|
27
27
|
buildProjectionToolbarActions,
|
|
28
|
+
buildRecordActions,
|
|
28
29
|
runProjectionRowNavigate,
|
|
29
30
|
} from "../app/row-actions";
|
|
30
31
|
import { findEditScreenFor } from "../app/screen-access";
|
|
@@ -36,6 +37,7 @@ import { useQuery } from "../hooks/use-query";
|
|
|
36
37
|
import { useTranslation } from "../i18n";
|
|
37
38
|
import { type DataTableFacet, usePrimitives } from "../primitives";
|
|
38
39
|
import { sortByAccessor } from "../sort-by-accessor";
|
|
40
|
+
import { RenderEditActionButton } from "./render-edit-action-button";
|
|
39
41
|
import { RenderList } from "./render-list";
|
|
40
42
|
|
|
41
43
|
const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
|
|
@@ -64,6 +66,17 @@ function synthesizeRelatedListEntity(
|
|
|
64
66
|
return { fields } as unknown as EntityDefinition;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
// Mirrors row-actions.ts's own warnDrawerActionDropped (module-private
|
|
70
|
+
// there, so not reusable directly) — a drawer-kind emptyState.action with no
|
|
71
|
+
// onOpenDrawer wired is dropped (not rendered) with a dev warning, same as a
|
|
72
|
+
// drawer-kind rowAction/toolbarAction without one.
|
|
73
|
+
function warnRelatedListEmptyStateDrawerDropped(actionId: string): void {
|
|
74
|
+
// biome-ignore lint/suspicious/noConsole: dev-time warning, same pattern as row-actions.ts
|
|
75
|
+
console.warn(
|
|
76
|
+
`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
67
80
|
export function RelatedListSection({
|
|
68
81
|
section,
|
|
69
82
|
parentId,
|
|
@@ -72,6 +85,7 @@ export function RelatedListSection({
|
|
|
72
85
|
translate,
|
|
73
86
|
hideTitle,
|
|
74
87
|
onOpenDrawer,
|
|
88
|
+
actions,
|
|
75
89
|
}: {
|
|
76
90
|
readonly section: EditRelatedListSectionViewModel;
|
|
77
91
|
readonly parentId: string;
|
|
@@ -89,8 +103,14 @@ export function RelatedListSection({
|
|
|
89
103
|
action: RowActionDrawer,
|
|
90
104
|
initialValues: Readonly<Record<string, unknown>> | undefined,
|
|
91
105
|
) => void;
|
|
106
|
+
/** section.actions, already resolved into buttons by the caller —
|
|
107
|
+
* rendered in the Section's title row. Only reachable in the standalone
|
|
108
|
+
* (non-`hideTitle`) branch below — `hideTitle` uses `FillContainer`, which
|
|
109
|
+
* has no title row to render actions into (see the comment on that branch). */
|
|
110
|
+
readonly actions?: ReactNode;
|
|
92
111
|
}): ReactNode {
|
|
93
|
-
const { Banner, Section, FillContainer } = usePrimitives();
|
|
112
|
+
const { Banner, Section, FillContainer, Text, Button, Dialog } = usePrimitives();
|
|
113
|
+
const [emptyStateActionError, setEmptyStateActionError] = useState<string | null>(null);
|
|
94
114
|
const t = useTranslation();
|
|
95
115
|
const effectiveTranslate = translate ?? t;
|
|
96
116
|
const nav = useNav();
|
|
@@ -301,6 +321,58 @@ export function RelatedListSection({
|
|
|
301
321
|
],
|
|
302
322
|
);
|
|
303
323
|
|
|
324
|
+
// emptyState.action is a RowAction — the same record-bound action shape
|
|
325
|
+
// (and evalRowExtractor payload/params evaluation) screen-level and
|
|
326
|
+
// section-level actions already use via buildRecordActions, NOT the
|
|
327
|
+
// toolbar-action builder (whose `payload` is a literal Record, not a
|
|
328
|
+
// RowFieldExtractor — casting a RowAction into it would silently skip the
|
|
329
|
+
// declared `pick`/`map` evaluation). "Row" here is the parent record: a
|
|
330
|
+
// relatedList's emptyState has no row of its own.
|
|
331
|
+
const emptyStateActionButton = useMemo(() => {
|
|
332
|
+
const action = section.emptyState?.action;
|
|
333
|
+
if (action === undefined) return undefined;
|
|
334
|
+
if (action.kind === "drawer" && onOpenDrawer === undefined) {
|
|
335
|
+
warnRelatedListEmptyStateDrawerDropped(action.id);
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
return buildRecordActions({
|
|
339
|
+
actions: [action],
|
|
340
|
+
record,
|
|
341
|
+
translate: effectiveTranslate,
|
|
342
|
+
nav,
|
|
343
|
+
host,
|
|
344
|
+
dispatcher,
|
|
345
|
+
openDrawer: onOpenDrawer ?? (() => {}),
|
|
346
|
+
onWriteSuccess: rowsQuery.refetch,
|
|
347
|
+
})?.[0];
|
|
348
|
+
}, [
|
|
349
|
+
section.emptyState,
|
|
350
|
+
record,
|
|
351
|
+
effectiveTranslate,
|
|
352
|
+
nav,
|
|
353
|
+
host,
|
|
354
|
+
dispatcher,
|
|
355
|
+
onOpenDrawer,
|
|
356
|
+
rowsQuery.refetch,
|
|
357
|
+
]);
|
|
358
|
+
const emptyStateContent =
|
|
359
|
+
section.emptyState !== undefined ? (
|
|
360
|
+
<>
|
|
361
|
+
<Text>{section.emptyState.title}</Text>
|
|
362
|
+
{section.emptyState.description !== undefined && (
|
|
363
|
+
<Text variant="small">{section.emptyState.description}</Text>
|
|
364
|
+
)}
|
|
365
|
+
{emptyStateActionButton !== undefined && (
|
|
366
|
+
<RenderEditActionButton
|
|
367
|
+
action={emptyStateActionButton}
|
|
368
|
+
Button={Button}
|
|
369
|
+
Dialog={Dialog}
|
|
370
|
+
onError={setEmptyStateActionError}
|
|
371
|
+
/>
|
|
372
|
+
)}
|
|
373
|
+
</>
|
|
374
|
+
) : undefined;
|
|
375
|
+
|
|
304
376
|
// A truncated fetch means `sortedRows` is a sort of a partial set, not of
|
|
305
377
|
// the full related-row set — the client-side sort above (or even plain
|
|
306
378
|
// unsorted display) would silently claim "these are the top N" when they
|
|
@@ -327,6 +399,11 @@ export function RelatedListSection({
|
|
|
327
399
|
})}
|
|
328
400
|
</Banner>
|
|
329
401
|
)}
|
|
402
|
+
{emptyStateActionError !== null && (
|
|
403
|
+
<Banner variant="error" testId="related-list-empty-state-action-error">
|
|
404
|
+
{emptyStateActionError}
|
|
405
|
+
</Banner>
|
|
406
|
+
)}
|
|
330
407
|
<RenderList
|
|
331
408
|
screen={listScreen}
|
|
332
409
|
entity={entity}
|
|
@@ -349,23 +426,21 @@ export function RelatedListSection({
|
|
|
349
426
|
{...(onRowClick !== undefined && { onRowClick })}
|
|
350
427
|
{...(rowActions !== undefined && { rowActions })}
|
|
351
428
|
{...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons })}
|
|
352
|
-
{...(
|
|
429
|
+
{...(emptyStateContent !== undefined && { emptyState: emptyStateContent })}
|
|
430
|
+
{...(hideTitle === true && { scrollBody: true, screenPadding: false, chromeless: true })}
|
|
353
431
|
/>
|
|
354
432
|
</>
|
|
355
433
|
);
|
|
356
434
|
|
|
357
|
-
// hideTitle (tabs mode) → the
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// Stacked (non-tabs) sections keep the card frame and document-flow
|
|
367
|
-
// height since they render a visible title and aren't confined to a tab
|
|
368
|
-
// panel.
|
|
435
|
+
// hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
|
|
436
|
+
// component's own return value in the same Card frame every other tab
|
|
437
|
+
// kind gets — no card here, or the two would nest. `scrollBody` caps the
|
|
438
|
+
// table to the panel height; `FillContainer` is this section's link in the
|
|
439
|
+
// fillHeight flex chain that Card's `options.fillHeight` continues (see
|
|
440
|
+
// render-edit.tsx). A platform primitive (not a raw `<div>`) because
|
|
441
|
+
// `renderer` stays DOM-free. Stacked (non-tabs) sections keep their own
|
|
442
|
+
// Section card and document-flow height since they render a visible title
|
|
443
|
+
// and aren't confined to a tab panel.
|
|
369
444
|
const bridges = <ReferenceFacetBridges specs={facetSpecs} onOptions={handleFacetOptions} />;
|
|
370
445
|
|
|
371
446
|
if (hideTitle) {
|
|
@@ -380,7 +455,11 @@ export function RelatedListSection({
|
|
|
380
455
|
return (
|
|
381
456
|
<>
|
|
382
457
|
{bridges}
|
|
383
|
-
<Section
|
|
458
|
+
<Section
|
|
459
|
+
title={section.title}
|
|
460
|
+
{...(actions !== undefined && { actions })}
|
|
461
|
+
testId={`related-list-${section.title}`}
|
|
462
|
+
>
|
|
384
463
|
{content}
|
|
385
464
|
</Section>
|
|
386
465
|
</>
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
EntityDefinition,
|
|
3
3
|
EntityEditScreenDefinition,
|
|
4
4
|
IconKey,
|
|
5
|
+
RowAction,
|
|
5
6
|
RowActionDrawer,
|
|
6
7
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
8
|
import type {
|
|
@@ -11,7 +12,7 @@ import type {
|
|
|
11
12
|
Translate,
|
|
12
13
|
} from "@cosmicdrift/kumiko-headless";
|
|
13
14
|
import type { ReactNode } from "react";
|
|
14
|
-
import type
|
|
15
|
+
import type * as z from "zod";
|
|
15
16
|
|
|
16
17
|
export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
17
18
|
readonly screen: EntityEditScreenDefinition;
|
|
@@ -160,7 +161,17 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
160
161
|
* width — for a host with its own header region (title/metrics/tabs)
|
|
161
162
|
* that would otherwise render as unpadded siblings before RenderEdit.
|
|
162
163
|
* Omitting this prop keeps unchanged behavior. */
|
|
163
|
-
readonly headerRegion?: ReactNode;
|
|
164
|
+
readonly headerRegion?: ReactNode | ((headerSlot: ReactNode | undefined) => ReactNode);
|
|
165
|
+
/** resolves an `EditFieldsSection`/`EditExtensionSection`/
|
|
166
|
+
* `EditRelatedListSection`/`EditWriteFormSection`'s own `actions` into
|
|
167
|
+
* already-bound buttons, rendered in that section's title row — same
|
|
168
|
+
* split as `onCopyLink`/header `actions`: RenderEdit has no dispatcher/nav
|
|
169
|
+
* context of its own, so the caller (which does) resolves each RowAction
|
|
170
|
+
* against the record being edited. Returns `undefined`/`[]` to render no
|
|
171
|
+
* actions for that section. */
|
|
172
|
+
readonly buildSectionActions?: (
|
|
173
|
+
actions: readonly RowAction[],
|
|
174
|
+
) => readonly RenderEditAction[] | undefined;
|
|
164
175
|
};
|
|
165
176
|
|
|
166
177
|
export type RenderEditAction = {
|
|
@@ -160,6 +160,7 @@ function ExtensionSectionMount({
|
|
|
160
160
|
patch,
|
|
161
161
|
validate,
|
|
162
162
|
hideTitle,
|
|
163
|
+
actions,
|
|
163
164
|
}: {
|
|
164
165
|
readonly section: EditExtensionSectionViewModel;
|
|
165
166
|
readonly entityName: string;
|
|
@@ -169,40 +170,61 @@ function ExtensionSectionMount({
|
|
|
169
170
|
readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
|
|
170
171
|
readonly validate?: () => boolean;
|
|
171
172
|
readonly hideTitle?: boolean;
|
|
173
|
+
/** section.actions, already resolved into buttons by the caller. */
|
|
174
|
+
readonly actions?: ReactNode;
|
|
172
175
|
}): ReactNode {
|
|
173
|
-
const { Banner, Section, Text } = usePrimitives();
|
|
176
|
+
const { Banner, FillContainer, Section, Text } = usePrimitives();
|
|
174
177
|
const name = extensionSectionName(section.component);
|
|
175
178
|
const Component = useExtensionSectionComponent(name);
|
|
179
|
+
const testId = `section-extension-${section.title}`;
|
|
176
180
|
if (Component === undefined) {
|
|
177
|
-
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
181
|
+
const placeholder = (
|
|
182
|
+
<Banner variant="info" testId={`section-extension-placeholder-${section.title}`}>
|
|
183
|
+
<Text>
|
|
184
|
+
Extension section component <Text variant="code">{name ?? "(no __component name)"}</Text>{" "}
|
|
185
|
+
not registered in clientFeatures.extensionSectionComponents.
|
|
186
|
+
</Text>
|
|
187
|
+
</Banner>
|
|
188
|
+
);
|
|
189
|
+
// Tabs mode already frames this in the caller's own Card — a Section
|
|
190
|
+
// here would add a second, unwanted divider inset (px-6 py-4) on top of
|
|
191
|
+
// the Card's own padding, misaligning this tab's content against the
|
|
192
|
+
// fields tab (Grid sits directly in the Card there). FillContainer keeps
|
|
193
|
+
// the testId without adding chrome (same primitive RelatedListSection
|
|
194
|
+
// uses for its own hideTitle branch).
|
|
195
|
+
return hideTitle === true ? (
|
|
196
|
+
FillContainer !== undefined ? (
|
|
197
|
+
<FillContainer testId={testId}>{placeholder}</FillContainer>
|
|
198
|
+
) : (
|
|
199
|
+
placeholder
|
|
200
|
+
)
|
|
201
|
+
) : (
|
|
202
|
+
<Section key={section.title} title={section.title} testId={testId}>
|
|
203
|
+
{placeholder}
|
|
190
204
|
</Section>
|
|
191
205
|
);
|
|
192
206
|
}
|
|
193
|
-
|
|
194
|
-
<
|
|
195
|
-
{
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
const mounted = (
|
|
208
|
+
<Component
|
|
209
|
+
entityName={section.entityName ?? hostEntityName}
|
|
210
|
+
entityId={entityId}
|
|
211
|
+
initialValues={initialValues}
|
|
212
|
+
values={values}
|
|
213
|
+
patch={patch}
|
|
214
|
+
validate={validate}
|
|
215
|
+
/>
|
|
216
|
+
);
|
|
217
|
+
// Same reasoning as the placeholder branch above: tabs mode's outer Card
|
|
218
|
+
// is the only frame this content gets, no nested Section divider.
|
|
219
|
+
return hideTitle === true ? (
|
|
220
|
+
FillContainer !== undefined ? (
|
|
221
|
+
<FillContainer testId={testId}>{mounted}</FillContainer>
|
|
222
|
+
) : (
|
|
223
|
+
mounted
|
|
224
|
+
)
|
|
225
|
+
) : (
|
|
226
|
+
<Section {...(actions !== undefined && { actions })} title={section.title} testId={testId}>
|
|
227
|
+
{mounted}
|
|
206
228
|
</Section>
|
|
207
229
|
);
|
|
208
230
|
}
|
|
@@ -289,6 +311,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
289
311
|
valueDisplay = "form",
|
|
290
312
|
hideSectionTitles,
|
|
291
313
|
headerRegion,
|
|
314
|
+
buildSectionActions,
|
|
292
315
|
} = props;
|
|
293
316
|
const { customSubmit } = props;
|
|
294
317
|
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
@@ -1111,10 +1134,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1111
1134
|
values={snapshot.values}
|
|
1112
1135
|
/>
|
|
1113
1136
|
) : undefined;
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1137
|
+
// A function headerRegion places the slot itself (projectionDetail puts it
|
|
1138
|
+
// into its header card next to the actions); otherwise the slot renders
|
|
1139
|
+
// above the caller's own headerRegion.
|
|
1116
1140
|
const formHeaderRegion =
|
|
1117
|
-
|
|
1141
|
+
typeof headerRegion === "function" ? (
|
|
1142
|
+
headerRegion(headerSlotMount)
|
|
1143
|
+
) : headerSlotMount !== undefined ? (
|
|
1118
1144
|
<>
|
|
1119
1145
|
{headerSlotMount}
|
|
1120
1146
|
{headerRegion}
|
|
@@ -1356,6 +1382,23 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1356
1382
|
);
|
|
1357
1383
|
})()}
|
|
1358
1384
|
{filteredSections.map((section: EditSectionViewModel, sectionIndex: number) => {
|
|
1385
|
+
// turns this section's own `actions` (RowAction[]) into
|
|
1386
|
+
// already-bound buttons via the caller-supplied builder, rendered
|
|
1387
|
+
// through the same RenderEditActionButton the top-level `actions`
|
|
1388
|
+
// prop uses (same style/confirm/icon/error handling).
|
|
1389
|
+
const sectionActions = section.actions;
|
|
1390
|
+
const sectionActionsEl: ReactNode =
|
|
1391
|
+
sectionActions !== undefined && sectionActions.length > 0
|
|
1392
|
+
? buildSectionActions?.(sectionActions)?.map((action) => (
|
|
1393
|
+
<RenderEditActionButton
|
|
1394
|
+
key={action.id}
|
|
1395
|
+
action={action}
|
|
1396
|
+
Button={Button}
|
|
1397
|
+
Dialog={Dialog}
|
|
1398
|
+
onError={setActionError}
|
|
1399
|
+
/>
|
|
1400
|
+
))
|
|
1401
|
+
: undefined;
|
|
1359
1402
|
// Wizard steps and tabs stay mounted while off-screen (native
|
|
1360
1403
|
// `hidden`, not unmounted) so an extension section's submit-registry
|
|
1361
1404
|
// entry (useExtensionFormSubmit → registry.remove on unmount)
|
|
@@ -1402,9 +1445,35 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1402
1445
|
}
|
|
1403
1446
|
validate={scopedValidate}
|
|
1404
1447
|
hideTitle={hideSectionTitles}
|
|
1448
|
+
// Tabs mode: actions move to the outer Card below — the inner
|
|
1449
|
+
// Section is flattened (rendered inside this component's own
|
|
1450
|
+
// Form) and would otherwise duplicate them.
|
|
1451
|
+
{...(hideSectionTitles !== true &&
|
|
1452
|
+
sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1405
1453
|
/>
|
|
1406
1454
|
);
|
|
1407
|
-
|
|
1455
|
+
// Section always flattens to a borderless divider inside this
|
|
1456
|
+
// component's own <Form>, so tabs mode frames it with Card
|
|
1457
|
+
// instead — same reason the fields-section tabs branch below
|
|
1458
|
+
// stays on Card. No title here: the tab strip already labels the
|
|
1459
|
+
// panel (matches the non-tabs Section branch above via hideTitle).
|
|
1460
|
+
// testId stays only on the inner Section — this outer Card is
|
|
1461
|
+
// purely chrome, giving it the same testId would register two
|
|
1462
|
+
// elements under one id.
|
|
1463
|
+
const wrapped =
|
|
1464
|
+
hideSectionTitles === true ? (
|
|
1465
|
+
<Card
|
|
1466
|
+
key={section.title}
|
|
1467
|
+
{...(sectionActionsEl !== undefined && {
|
|
1468
|
+
slots: { headerActions: sectionActionsEl },
|
|
1469
|
+
})}
|
|
1470
|
+
>
|
|
1471
|
+
{mount}
|
|
1472
|
+
</Card>
|
|
1473
|
+
) : (
|
|
1474
|
+
mount
|
|
1475
|
+
);
|
|
1476
|
+
return wrapWizardStep(section.title, wrapped);
|
|
1408
1477
|
}
|
|
1409
1478
|
if (section.kind === "relatedList") {
|
|
1410
1479
|
// parentId is the displayed record's id — without it there's no
|
|
@@ -1412,7 +1481,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1412
1481
|
// Rejected at boot in wizard layouts, so no WizardStepGroup here.
|
|
1413
1482
|
const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
1414
1483
|
if (parentId === null) return null;
|
|
1415
|
-
|
|
1484
|
+
const mount = (
|
|
1416
1485
|
<RelatedListSection
|
|
1417
1486
|
key={section.title}
|
|
1418
1487
|
section={section}
|
|
@@ -1422,11 +1491,36 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1422
1491
|
featureName={featureName}
|
|
1423
1492
|
translate={translate}
|
|
1424
1493
|
hideTitle={hideSectionTitles}
|
|
1494
|
+
// Tabs mode: actions move to the outer Card below — the
|
|
1495
|
+
// hideTitle branch of RelatedListSection has no title row to
|
|
1496
|
+
// render them into.
|
|
1497
|
+
{...(hideSectionTitles !== true &&
|
|
1498
|
+
sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1425
1499
|
{...(onRelatedListDrawerAction !== undefined && {
|
|
1426
1500
|
onOpenDrawer: onRelatedListDrawerAction,
|
|
1427
1501
|
})}
|
|
1428
1502
|
/>
|
|
1429
1503
|
);
|
|
1504
|
+
// Tabs mode: the relatedList tab must sit in the same Card frame
|
|
1505
|
+
// as every other tab (fields/extension/writeForm above) — the
|
|
1506
|
+
// acceptance criterion this unification exists for. `fillHeight`
|
|
1507
|
+
// on the Card threads the same fw#2722/#2778 flex chain the head
|
|
1508
|
+
// card already carries (DefaultCard's `options.fillHeight`), so
|
|
1509
|
+
// the table still scrolls inside the panel instead of the panel
|
|
1510
|
+
// stretching to the row count.
|
|
1511
|
+
return hideSectionTitles === true ? (
|
|
1512
|
+
<Card
|
|
1513
|
+
key={section.title}
|
|
1514
|
+
options={{ fillHeight }}
|
|
1515
|
+
{...(sectionActionsEl !== undefined && {
|
|
1516
|
+
slots: { headerActions: sectionActionsEl },
|
|
1517
|
+
})}
|
|
1518
|
+
>
|
|
1519
|
+
{mount}
|
|
1520
|
+
</Card>
|
|
1521
|
+
) : (
|
|
1522
|
+
mount
|
|
1523
|
+
);
|
|
1430
1524
|
}
|
|
1431
1525
|
if (section.kind === "writeForm") {
|
|
1432
1526
|
// Own submit button + dispatcher call, entirely independent of
|
|
@@ -1440,6 +1534,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1440
1534
|
translate={translate}
|
|
1441
1535
|
hideTitle={hideSectionTitles}
|
|
1442
1536
|
onSubmitted={() => onReload?.()}
|
|
1537
|
+
{...(sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1443
1538
|
/>
|
|
1444
1539
|
);
|
|
1445
1540
|
}
|
|
@@ -1478,10 +1573,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1478
1573
|
);
|
|
1479
1574
|
// Tabs mode renders one or more titled cards, so this card's actual
|
|
1480
1575
|
// heading doesn't visually duplicate the short Tab strip label.
|
|
1576
|
+
// Stays `Card`, not `Section` — Section always flattens to a
|
|
1577
|
+
// borderless divider when rendered inside this component's own
|
|
1578
|
+
// `<Form>` (InsideFormContext), even with `chromeless` set, so it
|
|
1579
|
+
// cannot stand in for the tabs-mode card here.
|
|
1481
1580
|
if (hideSectionTitles === true) {
|
|
1482
1581
|
if (section.groups !== undefined) {
|
|
1483
|
-
const
|
|
1484
|
-
<Grid key={sectionKey} columns={1}>
|
|
1582
|
+
const groupsGrid = (
|
|
1583
|
+
<Grid key={`${sectionKey}-groups`} columns={1}>
|
|
1485
1584
|
{section.groups.map((group, groupIndex) => (
|
|
1486
1585
|
<Card
|
|
1487
1586
|
key={group.title}
|
|
@@ -1497,12 +1596,23 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1497
1596
|
))}
|
|
1498
1597
|
</Grid>
|
|
1499
1598
|
);
|
|
1500
|
-
|
|
1599
|
+
// `actions` on a `groups` section has no group of its own to
|
|
1600
|
+
// sit in and would need an extra title-less Card around the
|
|
1601
|
+
// group cards (Card-in-Card) — the boot-validator rejects that
|
|
1602
|
+
// combination in tabs mode, so `sectionActionsEl` is always
|
|
1603
|
+
// undefined here at runtime.
|
|
1604
|
+
return wrapWizardStep(sectionKey, groupsGrid);
|
|
1501
1605
|
}
|
|
1606
|
+
// No section title here (fw#3218) — the Tab strip right above
|
|
1607
|
+
// already names this panel, so a repeated Card title would just
|
|
1608
|
+
// duplicate it. The title row only exists to carry actions, and
|
|
1609
|
+
// then it carries only the actions, never the title text.
|
|
1502
1610
|
const cardEl = (
|
|
1503
1611
|
<Card
|
|
1504
1612
|
key={sectionKey}
|
|
1505
|
-
{...(
|
|
1613
|
+
{...(sectionActionsEl !== undefined && {
|
|
1614
|
+
slots: { headerActions: sectionActionsEl },
|
|
1615
|
+
})}
|
|
1506
1616
|
testId={`section-${sectionKey}`}
|
|
1507
1617
|
>
|
|
1508
1618
|
{renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
|
|
@@ -1523,6 +1633,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1523
1633
|
{...(sectionTitle !== undefined && { title: sectionTitle })}
|
|
1524
1634
|
{...(section.description !== undefined && { subtitle: section.description })}
|
|
1525
1635
|
{...(section.icon !== undefined && { icon: section.icon })}
|
|
1636
|
+
{...(sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1526
1637
|
testId={`section-${sectionKey}`}
|
|
1527
1638
|
>
|
|
1528
1639
|
{renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
|
|
@@ -913,7 +913,9 @@ function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): stri
|
|
|
913
913
|
case "bigInt":
|
|
914
914
|
case "decimal": {
|
|
915
915
|
const n = numberValue(value);
|
|
916
|
-
return n === ""
|
|
916
|
+
return n === ""
|
|
917
|
+
? "—"
|
|
918
|
+
: new Intl.NumberFormat(appLocale, { useGrouping: field.grouping ?? true }).format(n);
|
|
917
919
|
}
|
|
918
920
|
case "money": {
|
|
919
921
|
const currency = resolveMoneyCurrency(value, field.currency);
|
|
@@ -123,10 +123,12 @@ export type RenderListProps = {
|
|
|
123
123
|
* scrolls rows internally instead of growing the page (relatedList in a
|
|
124
124
|
* tabs-mode section, fw#2722). Default false. */
|
|
125
125
|
readonly scrollBody?: boolean;
|
|
126
|
-
/** Forwarded to `DataTableProps.screenPadding` — the table carries
|
|
127
|
-
* shared screen padding because it is the screen body (entityList/
|
|
128
|
-
* projectionList, fw#2640).
|
|
129
|
-
*
|
|
126
|
+
/** Forwarded to `DataTableProps.screenPadding` — `true`: the table carries
|
|
127
|
+
* the shared screen padding because it is the screen body (entityList/
|
|
128
|
+
* projectionList, fw#2640). Explicit `false`: no padding at all — the
|
|
129
|
+
* host already pads its own children (relatedList in a tabs-mode Card,
|
|
130
|
+
* fw#3234). Omitted: legacy `p-6` fallback for a standalone table with no
|
|
131
|
+
* padded host. */
|
|
130
132
|
readonly screenPadding?: boolean;
|
|
131
133
|
};
|
|
132
134
|
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
} from "@cosmicdrift/kumiko-headless";
|
|
6
6
|
import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
|
|
7
7
|
import { type ReactNode, useMemo, useState } from "react";
|
|
8
|
-
import
|
|
8
|
+
import * as z from "zod";
|
|
9
9
|
import { REQUIRED_FIELD_I18N_KEY } from "../app/form-schema";
|
|
10
10
|
import { dispatcherErrorText } from "../app/write-failed-error";
|
|
11
11
|
import { useForm } from "../hooks/use-form";
|
|
@@ -52,6 +52,10 @@ export type WriteFormSectionProps = {
|
|
|
52
52
|
* record (a new one now exists) via a full RenderEdit remount, see
|
|
53
53
|
* ProjectionDetailBody's reloadNonce/key in kumiko-screen.tsx. */
|
|
54
54
|
readonly onSubmitted: () => void;
|
|
55
|
+
/** section.actions, already resolved into buttons by the caller —
|
|
56
|
+
* rendered alongside (before) the section's own submit button in the
|
|
57
|
+
* title-row actions slot. */
|
|
58
|
+
readonly actions?: ReactNode;
|
|
55
59
|
};
|
|
56
60
|
|
|
57
61
|
// A self-persisting form section for projectionDetail (see
|
|
@@ -65,8 +69,9 @@ export function WriteFormSection({
|
|
|
65
69
|
translate,
|
|
66
70
|
hideTitle,
|
|
67
71
|
onSubmitted,
|
|
72
|
+
actions,
|
|
68
73
|
}: WriteFormSectionProps): ReactNode {
|
|
69
|
-
const { Section, Grid, GridCell, Button, Banner } = usePrimitives();
|
|
74
|
+
const { Section, Card, Grid, GridCell, Button, Banner } = usePrimitives();
|
|
70
75
|
const t = useTranslation();
|
|
71
76
|
const effectiveTranslate = translate ?? t;
|
|
72
77
|
|
|
@@ -149,15 +154,43 @@ export function WriteFormSection({
|
|
|
149
154
|
</Button>
|
|
150
155
|
);
|
|
151
156
|
|
|
157
|
+
const titleRowActions =
|
|
158
|
+
actions !== undefined ? (
|
|
159
|
+
<>
|
|
160
|
+
{actions}
|
|
161
|
+
{submitButton}
|
|
162
|
+
</>
|
|
163
|
+
) : (
|
|
164
|
+
submitButton
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Section always flattens to a borderless divider when rendered inside
|
|
168
|
+
// RenderEdit's own <Form> in tabs mode (hideTitle) — Card frames it there
|
|
169
|
+
// instead, same reason render-edit.tsx's tabs-mode fields/extension
|
|
170
|
+
// branches stay on Card.
|
|
171
|
+
if (hideTitle) {
|
|
172
|
+
return (
|
|
173
|
+
<Card
|
|
174
|
+
slots={{
|
|
175
|
+
...(section.description !== undefined && { subtitle: section.description }),
|
|
176
|
+
headerActions: titleRowActions,
|
|
177
|
+
}}
|
|
178
|
+
testId={`write-form-${section.title ?? "section"}`}
|
|
179
|
+
>
|
|
180
|
+
{content}
|
|
181
|
+
</Card>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
152
185
|
// Routed through Section's `actions` slot (same mechanism render-edit.tsx
|
|
153
186
|
// uses via Form's `actions`) so the button gets the established right-
|
|
154
187
|
// aligned footer treatment instead of stretching full-width inline.
|
|
155
188
|
return (
|
|
156
189
|
<Section
|
|
157
|
-
{...(
|
|
190
|
+
{...(section.title !== undefined && { title: section.title })}
|
|
158
191
|
{...(section.description !== undefined && { subtitle: section.description })}
|
|
159
192
|
{...(section.icon !== undefined && { icon: section.icon })}
|
|
160
|
-
actions={
|
|
193
|
+
actions={titleRowActions}
|
|
161
194
|
testId={`write-form-${section.title ?? "section"}`}
|
|
162
195
|
>
|
|
163
196
|
{content}
|
package/src/primitives.tsx
CHANGED
|
@@ -862,9 +862,9 @@ export type SectionProps = {
|
|
|
862
862
|
* Kontext statt nur ein Label (analog FormProps.subtitle). */
|
|
863
863
|
readonly subtitle?: ReactNode;
|
|
864
864
|
readonly children: ReactNode;
|
|
865
|
-
/**
|
|
866
|
-
*
|
|
867
|
-
*
|
|
865
|
+
/** Rendered top-right in the title row — never a footer. A
|
|
866
|
+
* title-less/subtitle-less Section still draws the row when `actions`
|
|
867
|
+
* is set, so a hideTitle tabs-Section with actions isn't stranded. */
|
|
868
868
|
readonly actions?: ReactNode;
|
|
869
869
|
/** "destructive" marks the Section as a warning/danger area (e.g. account
|
|
870
870
|
* deletion, restrict processing) — border color only, no content change.
|
|
@@ -1046,6 +1046,10 @@ export type CardSlots = {
|
|
|
1046
1046
|
readonly header?: ReactNode;
|
|
1047
1047
|
readonly title?: ReactNode;
|
|
1048
1048
|
readonly subtitle?: ReactNode;
|
|
1049
|
+
/** Free-form block content left of `headerActions`, below title/subtitle —
|
|
1050
|
+
* for a host-supplied header (projectionDetail `slots.header`) that must
|
|
1051
|
+
* share the action row instead of pushing actions into a row of their own. */
|
|
1052
|
+
readonly headerContent?: ReactNode;
|
|
1049
1053
|
/** Rechtsbündiger Header-Slot (Range-Umschalter, Filter, Badge …). */
|
|
1050
1054
|
readonly headerActions?: ReactNode;
|
|
1051
1055
|
/** Footer-Inhalt, abgehoben (border-t). Default rechtsbündig. */
|
|
@@ -1060,6 +1064,10 @@ export type CardOptions = {
|
|
|
1060
1064
|
readonly radius?: "lg" | "xl";
|
|
1061
1065
|
/** Footer abgehoben mit border-t + bg-muted/30. Default true. */
|
|
1062
1066
|
readonly footerBordered?: boolean;
|
|
1067
|
+
/** Joins the ancestor fillHeight flex chain (fw#2722/#2778 — a tabs-mode
|
|
1068
|
+
* relatedList table sizing to the panel instead of the page) instead of
|
|
1069
|
+
* normal document-flow height. Default false. */
|
|
1070
|
+
readonly fillHeight?: boolean;
|
|
1063
1071
|
};
|
|
1064
1072
|
|
|
1065
1073
|
/** Generische Card-Chrome (border/bg/radius/shadow + Header/Body/Footer) als
|