@cosmicdrift/kumiko-renderer 0.245.0 → 0.246.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-shim.test.ts +21 -0
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +10 -1
- package/src/app/__tests__/projection-detail-actions.test.tsx +22 -12
- package/src/app/__tests__/projection-detail-shim.test.ts +19 -0
- package/src/app/action-form-shim.ts +1 -0
- package/src/app/kumiko-screen.tsx +40 -4
- package/src/app/projection-detail-shim.ts +1 -0
- package/src/components/__tests__/related-list-section.test.tsx +65 -0
- package/src/components/__tests__/render-edit-screen-description.test.tsx +132 -0
- package/src/components/related-list-section.tsx +5 -5
- package/src/components/render-edit.tsx +10 -2
- package/src/components/render-list.tsx +6 -0
- package/src/primitives.tsx +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.246.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.246.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.246.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
28
|
"jsdom": "^29.1.1",
|
|
29
29
|
"react-dom": "^19.2.6",
|
|
30
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.246.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
@@ -23,4 +23,25 @@ describe("synthesizeActionFormScreen", () => {
|
|
|
23
23
|
expect(screen.entity).toBe("__action-form__");
|
|
24
24
|
expect(screen.layout).toEqual({ sections: [{ title: "Invite", fields: ["email"] }] });
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
test("carries description through so RenderEdit can render it as the form subtitle (fw#2723)", () => {
|
|
28
|
+
const withDescription = synthesizeActionFormScreen({
|
|
29
|
+
id: "invite-user",
|
|
30
|
+
type: "actionForm",
|
|
31
|
+
description: "Invite a new team member by email.",
|
|
32
|
+
handler: "users:write:invite-user",
|
|
33
|
+
layout: { sections: [{ title: "Invite", fields: ["email"] }] },
|
|
34
|
+
fields: { email: { type: "text" } },
|
|
35
|
+
});
|
|
36
|
+
expect(withDescription.description).toBe("Invite a new team member by email.");
|
|
37
|
+
|
|
38
|
+
const withoutDescription = synthesizeActionFormScreen({
|
|
39
|
+
id: "invite-user",
|
|
40
|
+
type: "actionForm",
|
|
41
|
+
handler: "users:write:invite-user",
|
|
42
|
+
layout: { sections: [{ title: "Invite", fields: ["email"] }] },
|
|
43
|
+
fields: { email: { type: "text" } },
|
|
44
|
+
});
|
|
45
|
+
expect("description" in withoutDescription).toBe(false);
|
|
46
|
+
});
|
|
26
47
|
});
|
|
@@ -292,8 +292,17 @@ const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) =
|
|
|
292
292
|
</button>
|
|
293
293
|
);
|
|
294
294
|
|
|
295
|
-
|
|
295
|
+
// headerRegion carries projectionDetail's header actions since fw#2713 — a
|
|
296
|
+
// stub that only forwards children/actions/secondaryActions drops them
|
|
297
|
+
// silently, as this test's own action button did until it grew this slot.
|
|
298
|
+
const FormWithActions: ComponentType<FormProps> = ({
|
|
299
|
+
children,
|
|
300
|
+
actions,
|
|
301
|
+
secondaryActions,
|
|
302
|
+
headerRegion,
|
|
303
|
+
}) => (
|
|
296
304
|
<>
|
|
305
|
+
{headerRegion !== undefined && <div data-testid="form-header">{headerRegion}</div>}
|
|
297
306
|
<div data-testid="form-body">{children}</div>
|
|
298
307
|
<div data-testid="form-actions">
|
|
299
308
|
{secondaryActions}
|
|
@@ -43,13 +43,19 @@ const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) =
|
|
|
43
43
|
</button>
|
|
44
44
|
);
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
const FormWithActions: ComponentType<FormProps> = ({
|
|
46
|
+
// Header actions (and their error banner) now render inside ProjectionDetail's
|
|
47
|
+
// own head Card, passed through as `headerRegion` (fw#2713) rather than
|
|
48
|
+
// through Form's `actions`/`secondaryActions` slots — wrapped in a distinct
|
|
49
|
+
// testid'd container so a test can assert both live there, not in the
|
|
50
|
+
// actions/body regions Form itself owns.
|
|
51
|
+
const FormWithActions: ComponentType<FormProps> = ({
|
|
52
|
+
children,
|
|
53
|
+
actions,
|
|
54
|
+
secondaryActions,
|
|
55
|
+
headerRegion,
|
|
56
|
+
}) => (
|
|
52
57
|
<>
|
|
58
|
+
{headerRegion !== undefined && <div data-testid="form-header">{headerRegion}</div>}
|
|
53
59
|
<div data-testid="form-body">{children}</div>
|
|
54
60
|
<div data-testid="form-actions">
|
|
55
61
|
{secondaryActions}
|
|
@@ -410,7 +416,7 @@ describe("projectionDetail default edit action (fw#2166)", () => {
|
|
|
410
416
|
expect(navigated).toEqual({ screenId: "rent-edit", entityId: "rent-1" });
|
|
411
417
|
});
|
|
412
418
|
|
|
413
|
-
test("a failed writeHandler action shows its error in the
|
|
419
|
+
test("a failed writeHandler action shows its error in the head region, alongside the action button, NOT in Form's own actions/body regions (fw#2713)", async () => {
|
|
414
420
|
const schema: FeatureSchema = {
|
|
415
421
|
featureName: "app",
|
|
416
422
|
entities: {},
|
|
@@ -441,13 +447,17 @@ describe("projectionDetail default edit action (fw#2166)", () => {
|
|
|
441
447
|
|
|
442
448
|
const errorBanner = await waitFor(() => getByTestId("render-edit-action-error"));
|
|
443
449
|
expect(errorBanner.textContent).toBe("archive failed: rent is still active");
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
expect(errorBanner.closest('[data-testid="form-
|
|
450
|
+
// Header actions (and their failure banner) render in the head Card via
|
|
451
|
+
// `headerRegion` now (fw#2713) — not through Form's own actions/body
|
|
452
|
+
// slots, so both a tab switch and Form's own footer leave them untouched.
|
|
453
|
+
expect(errorBanner.closest('[data-testid="form-header"]')).not.toBeNull();
|
|
454
|
+
expect(errorBanner.closest('[data-testid="form-body"]')).toBeNull();
|
|
448
455
|
expect(errorBanner.closest('[data-testid="form-actions"]')).toBeNull();
|
|
449
456
|
expect(
|
|
450
|
-
getByTestId("render-edit-action-archive").closest('[data-testid="form-
|
|
457
|
+
getByTestId("render-edit-action-archive").closest('[data-testid="form-header"]'),
|
|
451
458
|
).not.toBeNull();
|
|
459
|
+
expect(
|
|
460
|
+
getByTestId("render-edit-action-archive").closest('[data-testid="form-actions"]'),
|
|
461
|
+
).toBeNull();
|
|
452
462
|
});
|
|
453
463
|
});
|
|
@@ -40,4 +40,23 @@ describe("synthesizeProjectionDetailScreen", () => {
|
|
|
40
40
|
}
|
|
41
41
|
expect(writeFormSection.fields[0]).toEqual({ field: "note", readOnly: false });
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
test("carries description through so RenderEdit can render it as the form subtitle (fw#2723)", () => {
|
|
45
|
+
const withDescription = synthesizeProjectionDetailScreen({
|
|
46
|
+
id: "order-detail",
|
|
47
|
+
type: "projectionDetail",
|
|
48
|
+
description: "Read-only view of a placed order.",
|
|
49
|
+
query: "orders:query:order:detail",
|
|
50
|
+
layout: { sections: [{ title: "Basics", fields: ["name"] }] },
|
|
51
|
+
});
|
|
52
|
+
expect(withDescription.description).toBe("Read-only view of a placed order.");
|
|
53
|
+
|
|
54
|
+
const withoutDescription = synthesizeProjectionDetailScreen({
|
|
55
|
+
id: "order-detail",
|
|
56
|
+
type: "projectionDetail",
|
|
57
|
+
query: "orders:query:order:detail",
|
|
58
|
+
layout: { sections: [{ title: "Basics", fields: ["name"] }] },
|
|
59
|
+
});
|
|
60
|
+
expect("description" in withoutDescription).toBe(false);
|
|
61
|
+
});
|
|
43
62
|
});
|
|
@@ -45,6 +45,7 @@ export function synthesizeActionFormScreen(
|
|
|
45
45
|
type: "entityEdit",
|
|
46
46
|
entity: ACTION_FORM_PSEUDO_ENTITY,
|
|
47
47
|
layout: screen.layout,
|
|
48
|
+
...(screen.description !== undefined && { description: screen.description }),
|
|
48
49
|
...(screen.access !== undefined && { access: screen.access }),
|
|
49
50
|
};
|
|
50
51
|
}
|
|
@@ -28,6 +28,7 @@ import { fieldLabelKey, fieldOptionLabelKey } from "@cosmicdrift/kumiko-headless
|
|
|
28
28
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
29
29
|
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog";
|
|
30
30
|
import { RenderEdit, type RenderEditAction } from "../components/render-edit";
|
|
31
|
+
import { RenderEditActionButton } from "../components/render-edit-action-button";
|
|
31
32
|
import { RenderList, type ToolbarActionButton } from "../components/render-list";
|
|
32
33
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
|
|
33
34
|
import { useUserRoles } from "../context/user-roles-context";
|
|
@@ -37,6 +38,7 @@ import { useTranslation } from "../i18n";
|
|
|
37
38
|
import {
|
|
38
39
|
type DataTableFacet,
|
|
39
40
|
type DataTableRowAction,
|
|
41
|
+
shouldRenderActionsIconOnly,
|
|
40
42
|
statusToneForValue,
|
|
41
43
|
usePrimitives,
|
|
42
44
|
} from "../primitives";
|
|
@@ -2174,7 +2176,7 @@ function ProjectionDetailBody({
|
|
|
2174
2176
|
readonly translate?: Translate;
|
|
2175
2177
|
readonly entityId?: string;
|
|
2176
2178
|
}): ReactNode {
|
|
2177
|
-
const { Banner, Text, Heading, Grid, GridCell, Card, Tabs, StatusBadge, Metric } =
|
|
2179
|
+
const { Banner, Button, Dialog, Text, Heading, Grid, GridCell, Card, Tabs, StatusBadge, Metric } =
|
|
2178
2180
|
usePrimitives();
|
|
2179
2181
|
const t = useTranslation();
|
|
2180
2182
|
const effectiveTranslate = translate ?? t;
|
|
@@ -2229,6 +2231,11 @@ function ProjectionDetailBody({
|
|
|
2229
2231
|
setReloadNonce((n) => n + 1);
|
|
2230
2232
|
}, [detailQuery.refetch]);
|
|
2231
2233
|
|
|
2234
|
+
// Header actions render directly via RenderEditActionButton (fw#2713,
|
|
2235
|
+
// headerActions moved out of RenderEdit's footer) — this component owns
|
|
2236
|
+
// the failure-surfacing state RenderEdit used to own for them.
|
|
2237
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
2238
|
+
|
|
2232
2239
|
// Default edit action (fw#2166): resolved cross-feature over ALL mounted
|
|
2233
2240
|
// features, not just this feature's own schema — detailFor itself is
|
|
2234
2241
|
// resolved cross-feature by the boot-validator (detail-screens.ts), and
|
|
@@ -2455,6 +2462,30 @@ function ProjectionDetailBody({
|
|
|
2455
2462
|
const hasHeader = screen.header !== undefined;
|
|
2456
2463
|
const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
|
|
2457
2464
|
const hasTabs = isTabsMode && Tabs !== undefined && activeSection !== undefined;
|
|
2465
|
+
// ?? [] rather than threading `headerActions !== undefined` through every
|
|
2466
|
+
// use below — an empty array is a safe no-op for .map/.length/icon-collapse.
|
|
2467
|
+
const headerActionsList = headerActions ?? [];
|
|
2468
|
+
const hasHeaderActions = headerActionsList.length > 0;
|
|
2469
|
+
const headerActionsIconOnly = shouldRenderActionsIconOnly(headerActionsList);
|
|
2470
|
+
// Grouped into the head Card alongside title/status/metrics (fw#2713):
|
|
2471
|
+
// these are actions on the record the head shows, not on whichever tab is
|
|
2472
|
+
// open, so they must stay in place across tab switches instead of
|
|
2473
|
+
// trailing the active tab's content in the card footer.
|
|
2474
|
+
const headerActionsContent = hasHeaderActions && (
|
|
2475
|
+
<Grid columns="auto" testId="kumiko-screen-projection-detail-actions">
|
|
2476
|
+
{headerActionsList.map((action) => (
|
|
2477
|
+
<RenderEditActionButton
|
|
2478
|
+
key={action.id}
|
|
2479
|
+
action={action}
|
|
2480
|
+
iconOnly={headerActionsIconOnly}
|
|
2481
|
+
Button={Button}
|
|
2482
|
+
Dialog={Dialog}
|
|
2483
|
+
onError={setActionError}
|
|
2484
|
+
/>
|
|
2485
|
+
))}
|
|
2486
|
+
</Grid>
|
|
2487
|
+
);
|
|
2488
|
+
const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
|
|
2458
2489
|
// Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
|
|
2459
2490
|
// this shares the same page padding/width as the record's edit card below
|
|
2460
2491
|
// it, instead of sitting flush against the screen edge (fw record-screen
|
|
@@ -2462,7 +2493,7 @@ function ProjectionDetailBody({
|
|
|
2462
2493
|
const header = screen.header;
|
|
2463
2494
|
const headerContent = (
|
|
2464
2495
|
<>
|
|
2465
|
-
{
|
|
2496
|
+
{hasHeaderCard && (
|
|
2466
2497
|
<Card>
|
|
2467
2498
|
{header !== undefined && (
|
|
2468
2499
|
<>
|
|
@@ -2515,6 +2546,12 @@ function ProjectionDetailBody({
|
|
|
2515
2546
|
})}
|
|
2516
2547
|
</Grid>
|
|
2517
2548
|
)}
|
|
2549
|
+
{headerActionsContent}
|
|
2550
|
+
{actionError !== null && (
|
|
2551
|
+
<Banner variant="error" testId="render-edit-action-error">
|
|
2552
|
+
{actionError}
|
|
2553
|
+
</Banner>
|
|
2554
|
+
)}
|
|
2518
2555
|
</Card>
|
|
2519
2556
|
)}
|
|
2520
2557
|
{hasTabs && activeSection !== undefined && (
|
|
@@ -2542,10 +2579,9 @@ function ProjectionDetailBody({
|
|
|
2542
2579
|
customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
|
|
2543
2580
|
onReload={reloadDetail}
|
|
2544
2581
|
onRelatedListDrawerAction={openDrawer}
|
|
2545
|
-
{...(headerActions !== undefined && { actions: headerActions })}
|
|
2546
2582
|
{...(translate !== undefined && { translate })}
|
|
2547
2583
|
{...(hasTabs && { hideSectionTitles: true })}
|
|
2548
|
-
{...((
|
|
2584
|
+
{...((hasHeaderCard || hasTabs) && { headerRegion: headerContent })}
|
|
2549
2585
|
valueDisplay={screen.valueDisplay ?? "text"}
|
|
2550
2586
|
/>
|
|
2551
2587
|
<DrawerHost
|
|
@@ -67,6 +67,7 @@ export function synthesizeProjectionDetailScreen(
|
|
|
67
67
|
layout: { sections, ...(screen.layout.width !== undefined && { width: screen.layout.width }) },
|
|
68
68
|
allowCreate: false,
|
|
69
69
|
allowDelete: false,
|
|
70
|
+
...(screen.description !== undefined && { description: screen.description }),
|
|
70
71
|
...(screen.fieldLabels !== undefined && { fieldLabels: screen.fieldLabels }),
|
|
71
72
|
...(screen.slots !== undefined && { slots: screen.slots }),
|
|
72
73
|
...(screen.access !== undefined && { access: screen.access }),
|
|
@@ -167,6 +167,71 @@ function renderRelatedList(
|
|
|
167
167
|
);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
|
|
171
|
+
test("hideTitle (tabs mode) renders the list without a Section wrapper and marks the table chromeless", async () => {
|
|
172
|
+
const { dispatcher } = stubDispatcher();
|
|
173
|
+
let capturedChromeless: boolean | undefined;
|
|
174
|
+
const capturingDataTable: ComponentType<DataTableProps> = (props) => {
|
|
175
|
+
capturedChromeless = props.chromeless;
|
|
176
|
+
return testDataTable(props);
|
|
177
|
+
};
|
|
178
|
+
render(
|
|
179
|
+
<LocaleProvider
|
|
180
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
181
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
182
|
+
>
|
|
183
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
184
|
+
<PrimitivesProvider value={{ ...testPrimitives(), DataTable: capturingDataTable }}>
|
|
185
|
+
<NavProvider value={stubNav().nav}>
|
|
186
|
+
<RelatedListSection
|
|
187
|
+
section={historySection}
|
|
188
|
+
parentId="order-1"
|
|
189
|
+
featureName="orders"
|
|
190
|
+
hideTitle
|
|
191
|
+
/>
|
|
192
|
+
</NavProvider>
|
|
193
|
+
</PrimitivesProvider>
|
|
194
|
+
</DispatcherProvider>
|
|
195
|
+
</LocaleProvider>,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
199
|
+
expect(rtlScreen.queryByTestId(`related-list-${historySection.title}`)).toBeNull();
|
|
200
|
+
expect(capturedChromeless).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("without hideTitle (stacked mode), the same section keeps its Section wrapper and an un-chromeless table", async () => {
|
|
204
|
+
const { dispatcher } = stubDispatcher();
|
|
205
|
+
let capturedChromeless: boolean | undefined;
|
|
206
|
+
const capturingDataTable: ComponentType<DataTableProps> = (props) => {
|
|
207
|
+
capturedChromeless = props.chromeless;
|
|
208
|
+
return testDataTable(props);
|
|
209
|
+
};
|
|
210
|
+
render(
|
|
211
|
+
<LocaleProvider
|
|
212
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
213
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
214
|
+
>
|
|
215
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
216
|
+
<PrimitivesProvider value={{ ...testPrimitives(), DataTable: capturingDataTable }}>
|
|
217
|
+
<NavProvider value={stubNav().nav}>
|
|
218
|
+
<RelatedListSection
|
|
219
|
+
section={historySection}
|
|
220
|
+
parentId="order-1"
|
|
221
|
+
featureName="orders"
|
|
222
|
+
/>
|
|
223
|
+
</NavProvider>
|
|
224
|
+
</PrimitivesProvider>
|
|
225
|
+
</DispatcherProvider>
|
|
226
|
+
</LocaleProvider>,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
230
|
+
expect(rtlScreen.getByTestId(`related-list-${historySection.title}`)).toBeTruthy();
|
|
231
|
+
expect(capturedChromeless).toBeUndefined();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
170
235
|
describe("RelatedListSection — rowActions", () => {
|
|
171
236
|
test("clicking a row action dispatches through the configured write-handler with the extracted payload, then refetches", async () => {
|
|
172
237
|
const { dispatcher, writes, queryCount } = stubDispatcher();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Regression coverage for kumiko-framework#2723: `EntityEditScreenDefinition.
|
|
2
|
+
// description` / `ActionFormScreenDefinition.description` existed in the type
|
|
3
|
+
// but was never rendered anywhere — used only as agent/API metadata
|
|
4
|
+
// (packages/framework/src/api/server.ts). RenderEdit now renders it as the
|
|
5
|
+
// form's subtitle, same visual slot section.description already fills for a
|
|
6
|
+
// section (render-edit.tsx's `Section`), falling back to it only when no
|
|
7
|
+
// `screen:<id>.subtitle` i18n override is set. `description` is run through
|
|
8
|
+
// translate(): an i18n key (the established convention, e.g.
|
|
9
|
+
// user-data-rights/feature.ts) resolves to its translation, plain prose
|
|
10
|
+
// passes through unchanged (review finding on this PR).
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import type {
|
|
14
|
+
EntityDefinition,
|
|
15
|
+
EntityEditScreenDefinition,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
|
+
import { render } from "@testing-library/react";
|
|
18
|
+
import type { ReactNode } from "react";
|
|
19
|
+
import { createStaticLocaleResolver, LocaleProvider, type TranslationsByLocale } from "../../i18n";
|
|
20
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
21
|
+
import { type CorePrimitives, type FormProps, PrimitivesProvider } from "../../primitives";
|
|
22
|
+
import { RenderEdit } from "../render-edit";
|
|
23
|
+
|
|
24
|
+
const noop = (): ReactNode => null;
|
|
25
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
26
|
+
|
|
27
|
+
function buildEntity(): EntityDefinition {
|
|
28
|
+
return {
|
|
29
|
+
fields: {
|
|
30
|
+
name: { type: "text", maxLength: 200, required: false, searchable: false, sortable: false },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderEditCapturingForm(
|
|
36
|
+
screen: EntityEditScreenDefinition,
|
|
37
|
+
extraTranslations?: TranslationsByLocale,
|
|
38
|
+
): {
|
|
39
|
+
captured: FormProps | undefined;
|
|
40
|
+
} {
|
|
41
|
+
let captured: FormProps | undefined;
|
|
42
|
+
const capturingForm = (props: FormProps): ReactNode => {
|
|
43
|
+
captured = props;
|
|
44
|
+
return props.children;
|
|
45
|
+
};
|
|
46
|
+
const primitives: CorePrimitives = {
|
|
47
|
+
Button: noop,
|
|
48
|
+
Banner: noop,
|
|
49
|
+
Field: passChildren,
|
|
50
|
+
Input: noop,
|
|
51
|
+
DataTable: noop,
|
|
52
|
+
Form: capturingForm,
|
|
53
|
+
Section: passChildren,
|
|
54
|
+
Card: passChildren,
|
|
55
|
+
Grid: passChildren,
|
|
56
|
+
GridCell: passChildren,
|
|
57
|
+
Text: passChildren,
|
|
58
|
+
Heading: noop,
|
|
59
|
+
Dialog: noop,
|
|
60
|
+
Modal: noop,
|
|
61
|
+
Lightbox: noop,
|
|
62
|
+
ConfigSourceBadge: noop,
|
|
63
|
+
ConfigCascadeView: noop,
|
|
64
|
+
Link: noop,
|
|
65
|
+
};
|
|
66
|
+
render(
|
|
67
|
+
<LocaleProvider
|
|
68
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
69
|
+
fallbackBundles={
|
|
70
|
+
extraTranslations !== undefined
|
|
71
|
+
? [extraTranslations, kumikoDefaultTranslations]
|
|
72
|
+
: [kumikoDefaultTranslations]
|
|
73
|
+
}
|
|
74
|
+
>
|
|
75
|
+
<PrimitivesProvider value={primitives}>
|
|
76
|
+
<RenderEdit
|
|
77
|
+
screen={screen}
|
|
78
|
+
entity={buildEntity()}
|
|
79
|
+
featureName="widgets"
|
|
80
|
+
initial={{ name: "" }}
|
|
81
|
+
/>
|
|
82
|
+
</PrimitivesProvider>
|
|
83
|
+
</LocaleProvider>,
|
|
84
|
+
);
|
|
85
|
+
return { captured };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("RenderEdit — screen.description as form subtitle (fw#2723)", () => {
|
|
89
|
+
test("a screen with description renders it as the form's subtitle", () => {
|
|
90
|
+
const screen: EntityEditScreenDefinition = {
|
|
91
|
+
id: "widget-edit",
|
|
92
|
+
type: "entityEdit",
|
|
93
|
+
entity: "widget",
|
|
94
|
+
description: "Edit the widget's basic details.",
|
|
95
|
+
layout: { sections: [{ title: "Basics", fields: ["name"] }] },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const { captured } = renderEditCapturingForm(screen);
|
|
99
|
+
|
|
100
|
+
expect(captured?.subtitle).toBe("Edit the widget's basic details.");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a description that is a known i18n key renders translated, not as the raw key", () => {
|
|
104
|
+
const screen: EntityEditScreenDefinition = {
|
|
105
|
+
id: "widget-edit",
|
|
106
|
+
type: "entityEdit",
|
|
107
|
+
entity: "widget",
|
|
108
|
+
description: "widgets:widget-edit.explainer",
|
|
109
|
+
layout: { sections: [{ title: "Basics", fields: ["name"] }] },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const { captured } = renderEditCapturingForm(screen, {
|
|
113
|
+
"en-US": { "widgets:widget-edit.explainer": "Every field here is optional." },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(captured?.subtitle).toBe("Every field here is optional.");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a screen without description renders no subtitle prop at all (no empty placeholder)", () => {
|
|
120
|
+
const screen: EntityEditScreenDefinition = {
|
|
121
|
+
id: "widget-edit",
|
|
122
|
+
type: "entityEdit",
|
|
123
|
+
entity: "widget",
|
|
124
|
+
layout: { sections: [{ title: "Basics", fields: ["name"] }] },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const { captured } = renderEditCapturingForm(screen);
|
|
128
|
+
|
|
129
|
+
expect(captured).toBeDefined();
|
|
130
|
+
expect(captured && "subtitle" in captured).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -155,14 +155,14 @@ export function RelatedListSection({
|
|
|
155
155
|
{...(onRowClick !== undefined && { onRowClick })}
|
|
156
156
|
{...(rowActions !== undefined && { rowActions })}
|
|
157
157
|
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
158
|
+
{...(hideTitle === true && { chromeless: true })}
|
|
158
159
|
/>
|
|
159
160
|
);
|
|
160
161
|
|
|
161
|
-
// hideTitle (tabs mode) →
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// card since they render a visible title.
|
|
162
|
+
// hideTitle (tabs mode) → the tab panel is already the boundary: no
|
|
163
|
+
// Section card wrapper here, and `chromeless` above drops the table's own
|
|
164
|
+
// card frame too (fw#2722) — the list sits directly in the tab. Stacked
|
|
165
|
+
// (non-tabs) sections keep both cards since they render a visible title.
|
|
166
166
|
if (hideTitle) return content;
|
|
167
167
|
|
|
168
168
|
return (
|
|
@@ -1036,7 +1036,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1036
1036
|
// Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
|
|
1037
1037
|
// screen:<id>.<mode>.title / .<mode>.subtitle
|
|
1038
1038
|
// Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
|
|
1039
|
-
// title falls back to screenId; subtitle
|
|
1039
|
+
// title falls back to screenId; subtitle falls back to screen.description,
|
|
1040
|
+
// itself run through translate() so an i18n key (the established
|
|
1041
|
+
// convention for description, e.g. bundled-features) resolves instead of
|
|
1042
|
+
// showing the raw key — translate() returns the input unchanged for plain
|
|
1043
|
+
// prose, same as it does for an unknown key (fw#2723 review). Same slot
|
|
1044
|
+
// section.description already fills for a section. Falls back to
|
|
1045
|
+
// undefined (no subtitle) when neither is set.
|
|
1040
1046
|
const isCreate = (() => {
|
|
1041
1047
|
const id = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
1042
1048
|
return id == null || id === "";
|
|
@@ -1053,7 +1059,9 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1053
1059
|
return undefined;
|
|
1054
1060
|
};
|
|
1055
1061
|
const formTitle = resolveScreenText("title") ?? screen.id;
|
|
1056
|
-
const formSubtitle =
|
|
1062
|
+
const formSubtitle =
|
|
1063
|
+
resolveScreenText("subtitle") ??
|
|
1064
|
+
(screen.description !== undefined ? translate(screen.description) : undefined);
|
|
1057
1065
|
|
|
1058
1066
|
return (
|
|
1059
1067
|
<ExtensionFormRegistryProvider value={extensionFormRegistry}>
|
|
@@ -110,6 +110,10 @@ export type RenderListProps = {
|
|
|
110
110
|
readonly onFilterChange?: (field: string, values: readonly string[]) => void;
|
|
111
111
|
/** Reset aller aktiven Facets. */
|
|
112
112
|
readonly onFilterReset?: () => void;
|
|
113
|
+
/** Forwarded to `DataTableProps.chromeless` — drops the table's own card
|
|
114
|
+
* frame for a host that already provides one (relatedList in a tabs-mode
|
|
115
|
+
* section, fw#2722). Default false. */
|
|
116
|
+
readonly chromeless?: boolean;
|
|
113
117
|
};
|
|
114
118
|
|
|
115
119
|
// Resolved-Form einer Toolbar-Action: KumikoScreen baut das aus dem
|
|
@@ -158,6 +162,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
158
162
|
filterValues,
|
|
159
163
|
onFilterChange,
|
|
160
164
|
onFilterReset,
|
|
165
|
+
chromeless,
|
|
161
166
|
} = props;
|
|
162
167
|
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
163
168
|
// wären Column-Header raw i18n-Keys.
|
|
@@ -372,6 +377,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
372
377
|
{...(filterValues !== undefined && { filterValues })}
|
|
373
378
|
{...(onFilterChange !== undefined && { onFilterChange })}
|
|
374
379
|
{...(onFilterReset !== undefined && { onFilterReset })}
|
|
380
|
+
{...(chromeless !== undefined && { chromeless })}
|
|
375
381
|
testId="render-list-table"
|
|
376
382
|
/>
|
|
377
383
|
</>
|
package/src/primitives.tsx
CHANGED
|
@@ -618,6 +618,11 @@ export type DataTableProps = {
|
|
|
618
618
|
readonly getRowTestId?: (row: ListRowViewModel) => string;
|
|
619
619
|
/** Overrides the cell `data-testid` (default `cell-${row.id}-${field}`). */
|
|
620
620
|
readonly getCellTestId?: (row: ListRowViewModel, field: string) => string;
|
|
621
|
+
/** Drops the table's own rounded/border/card background — for a host
|
|
622
|
+
* that already provides the surrounding boundary (a tab panel, fw#2722)
|
|
623
|
+
* and would otherwise show a card nested inside that boundary. Default
|
|
624
|
+
* false: unchanged card-framed table. */
|
|
625
|
+
readonly chromeless?: boolean;
|
|
621
626
|
};
|
|
622
627
|
|
|
623
628
|
// ---- EmbeddedListInput (createEmbeddedListField widget) ----
|