@cosmicdrift/kumiko-renderer 0.245.0 → 0.247.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/app/row-actions.ts +21 -2
- package/src/components/__tests__/related-list-section.test.tsx +328 -4
- package/src/components/__tests__/render-edit-screen-description.test.tsx +132 -0
- package/src/components/related-list-section.tsx +79 -22
- package/src/components/render-edit.tsx +20 -2
- package/src/components/render-list.tsx +12 -0
- package/src/i18n-defaults.ts +2 -0
- package/src/index.ts +1 -0
- package/src/primitives.tsx +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.247.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.247.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.247.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.247.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 }),
|
package/src/app/row-actions.ts
CHANGED
|
@@ -159,6 +159,21 @@ type OpenDrawer = (
|
|
|
159
159
|
initialValues: Readonly<Record<string, unknown>> | undefined,
|
|
160
160
|
) => void;
|
|
161
161
|
|
|
162
|
+
// buildProjectionRowActions runs inside a useMemo (re-evaluated on every dep
|
|
163
|
+
// change), so this dedupes the "openDrawer not wired" warning per action id
|
|
164
|
+
// instead of firing on every recompute.
|
|
165
|
+
const warnedDrawerRowActionIds = new Set<string>();
|
|
166
|
+
|
|
167
|
+
function warnDrawerActionDropped(actionId: string): void {
|
|
168
|
+
// skip: already warned for this id — suppresses the repeat, not the warning itself.
|
|
169
|
+
if (warnedDrawerRowActionIds.has(actionId)) return;
|
|
170
|
+
warnedDrawerRowActionIds.add(actionId);
|
|
171
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
|
|
172
|
+
console.warn(
|
|
173
|
+
`[kumiko] rowAction "${actionId}" is kind:"drawer", but the host did not wire openDrawer (RelatedListSection: pass onOpenDrawer) — it will not render.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
162
177
|
function buildDrawerRowAction(
|
|
163
178
|
action: RowActionDrawer,
|
|
164
179
|
translate: Translate,
|
|
@@ -193,7 +208,8 @@ export function buildProjectionRowActions(options: {
|
|
|
193
208
|
readonly nav: NavApi;
|
|
194
209
|
readonly refetch: () => Promise<unknown>;
|
|
195
210
|
/** Opens the drawer-kind action's target actionForm, prefilled from the
|
|
196
|
-
* clicked row. Omitted callers (
|
|
211
|
+
* clicked row. Omitted callers (e.g. RelatedListSection embedded directly
|
|
212
|
+
* without onOpenDrawer) drop drawer actions and get a dev warning —
|
|
197
213
|
* mirrors the `dispatcher === undefined` skip below for writeHandler. */
|
|
198
214
|
readonly openDrawer?: OpenDrawer;
|
|
199
215
|
}): readonly DataTableRowAction[] | undefined {
|
|
@@ -206,7 +222,10 @@ export function buildProjectionRowActions(options: {
|
|
|
206
222
|
continue;
|
|
207
223
|
}
|
|
208
224
|
if (action.kind === "drawer") {
|
|
209
|
-
if (openDrawer === undefined)
|
|
225
|
+
if (openDrawer === undefined) {
|
|
226
|
+
warnDrawerActionDropped(action.id);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
210
229
|
out.push(buildDrawerRowAction(action, translate, openDrawer));
|
|
211
230
|
continue;
|
|
212
231
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import type { RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
|
|
4
|
-
import { render, screen as rtlScreen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
|
|
5
5
|
import type { ComponentType, ReactNode } from "react";
|
|
6
6
|
import { type NavApi, NavProvider } from "../../app/nav";
|
|
7
7
|
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
@@ -79,7 +79,10 @@ function testPrimitives(): CorePrimitives {
|
|
|
79
79
|
} as unknown as CorePrimitives;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
function stubDispatcher(
|
|
82
|
+
function stubDispatcher(
|
|
83
|
+
rows: readonly Record<string, unknown>[] = [{ id: "r1", name: "Alice" }],
|
|
84
|
+
nextCursor: string | null = null,
|
|
85
|
+
): {
|
|
83
86
|
dispatcher: Dispatcher;
|
|
84
87
|
writes: Array<{ type: string; payload: unknown }>;
|
|
85
88
|
queryCount: () => number;
|
|
@@ -93,7 +96,7 @@ function stubDispatcher(rows: readonly Record<string, unknown>[] = [{ id: "r1",
|
|
|
93
96
|
}) as Dispatcher["write"],
|
|
94
97
|
query: (async () => {
|
|
95
98
|
queryCalls += 1;
|
|
96
|
-
return { isSuccess: true, data: { rows, nextCursor
|
|
99
|
+
return { isSuccess: true, data: { rows, nextCursor } };
|
|
97
100
|
}) as Dispatcher["query"],
|
|
98
101
|
batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
|
|
99
102
|
statusStore: {
|
|
@@ -167,6 +170,77 @@ function renderRelatedList(
|
|
|
167
170
|
);
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
|
|
174
|
+
test("hideTitle (tabs mode) renders the list without a Section wrapper and marks the table chromeless + scrollBody", async () => {
|
|
175
|
+
const { dispatcher } = stubDispatcher();
|
|
176
|
+
let capturedChromeless: boolean | undefined;
|
|
177
|
+
let capturedScrollBody: boolean | undefined;
|
|
178
|
+
const capturingDataTable: ComponentType<DataTableProps> = (props) => {
|
|
179
|
+
capturedChromeless = props.chromeless;
|
|
180
|
+
capturedScrollBody = props.scrollBody;
|
|
181
|
+
return testDataTable(props);
|
|
182
|
+
};
|
|
183
|
+
render(
|
|
184
|
+
<LocaleProvider
|
|
185
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
186
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
187
|
+
>
|
|
188
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
189
|
+
<PrimitivesProvider value={{ ...testPrimitives(), DataTable: capturingDataTable }}>
|
|
190
|
+
<NavProvider value={stubNav().nav}>
|
|
191
|
+
<RelatedListSection
|
|
192
|
+
section={historySection}
|
|
193
|
+
parentId="order-1"
|
|
194
|
+
featureName="orders"
|
|
195
|
+
hideTitle
|
|
196
|
+
/>
|
|
197
|
+
</NavProvider>
|
|
198
|
+
</PrimitivesProvider>
|
|
199
|
+
</DispatcherProvider>
|
|
200
|
+
</LocaleProvider>,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
204
|
+
expect(rtlScreen.queryByTestId(`related-list-${historySection.title}`)).toBeNull();
|
|
205
|
+
expect(capturedChromeless).toBe(true);
|
|
206
|
+
expect(capturedScrollBody).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("without hideTitle (stacked mode), the same section keeps its Section wrapper and an un-chromeless, unbounded-height table", async () => {
|
|
210
|
+
const { dispatcher } = stubDispatcher();
|
|
211
|
+
let capturedChromeless: boolean | undefined;
|
|
212
|
+
let capturedScrollBody: boolean | undefined;
|
|
213
|
+
const capturingDataTable: ComponentType<DataTableProps> = (props) => {
|
|
214
|
+
capturedChromeless = props.chromeless;
|
|
215
|
+
capturedScrollBody = props.scrollBody;
|
|
216
|
+
return testDataTable(props);
|
|
217
|
+
};
|
|
218
|
+
render(
|
|
219
|
+
<LocaleProvider
|
|
220
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
221
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
222
|
+
>
|
|
223
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
224
|
+
<PrimitivesProvider value={{ ...testPrimitives(), DataTable: capturingDataTable }}>
|
|
225
|
+
<NavProvider value={stubNav().nav}>
|
|
226
|
+
<RelatedListSection
|
|
227
|
+
section={historySection}
|
|
228
|
+
parentId="order-1"
|
|
229
|
+
featureName="orders"
|
|
230
|
+
/>
|
|
231
|
+
</NavProvider>
|
|
232
|
+
</PrimitivesProvider>
|
|
233
|
+
</DispatcherProvider>
|
|
234
|
+
</LocaleProvider>,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
238
|
+
expect(rtlScreen.getByTestId(`related-list-${historySection.title}`)).toBeTruthy();
|
|
239
|
+
expect(capturedChromeless).toBeUndefined();
|
|
240
|
+
expect(capturedScrollBody).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
170
244
|
describe("RelatedListSection — rowActions", () => {
|
|
171
245
|
test("clicking a row action dispatches through the configured write-handler with the extracted payload, then refetches", async () => {
|
|
172
246
|
const { dispatcher, writes, queryCount } = stubDispatcher();
|
|
@@ -313,4 +387,254 @@ describe("RelatedListSection — rowActions drawer-kind (fw#2710)", () => {
|
|
|
313
387
|
await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
|
|
314
388
|
expect(rtlScreen.queryByTestId("action-adjust-rent-item-7")).toBeNull();
|
|
315
389
|
});
|
|
390
|
+
|
|
391
|
+
test("a dropped drawer rowAction without onOpenDrawer logs a dev warning naming the action id (fw#2733)", async () => {
|
|
392
|
+
const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
|
|
393
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
394
|
+
try {
|
|
395
|
+
renderRelatedList(dispatcher, {
|
|
396
|
+
kind: "relatedList",
|
|
397
|
+
title: "Positions",
|
|
398
|
+
query: "lease:query:items:list",
|
|
399
|
+
columns: [{ field: "name" }],
|
|
400
|
+
rowActions: [
|
|
401
|
+
{
|
|
402
|
+
kind: "drawer",
|
|
403
|
+
id: "adjust-rent-warn-only-2733",
|
|
404
|
+
label: "actions.adjustRent",
|
|
405
|
+
screen: "adjust-rent-form",
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
|
|
411
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
412
|
+
expect(warnSpy.mock.calls[0]?.[0]).toContain("adjust-rent-warn-only-2733");
|
|
413
|
+
} finally {
|
|
414
|
+
warnSpy.mockRestore();
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("a drawer rowAction with onOpenDrawer wired renders without a dev warning (fw#2733)", async () => {
|
|
419
|
+
const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
|
|
420
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
421
|
+
try {
|
|
422
|
+
render(
|
|
423
|
+
<LocaleProvider
|
|
424
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
425
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
426
|
+
>
|
|
427
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
428
|
+
<PrimitivesProvider value={testPrimitives()}>
|
|
429
|
+
<NavProvider value={stubNav().nav}>
|
|
430
|
+
<RelatedListSection
|
|
431
|
+
section={{
|
|
432
|
+
kind: "relatedList",
|
|
433
|
+
title: "Positions",
|
|
434
|
+
query: "lease:query:items:list",
|
|
435
|
+
columns: [{ field: "name" }],
|
|
436
|
+
rowActions: [
|
|
437
|
+
{
|
|
438
|
+
kind: "drawer",
|
|
439
|
+
id: "adjust-rent-warn-wired-2733",
|
|
440
|
+
label: "actions.adjustRent",
|
|
441
|
+
screen: "adjust-rent-form",
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
}}
|
|
445
|
+
parentId="order-1"
|
|
446
|
+
featureName="orders"
|
|
447
|
+
onOpenDrawer={noop}
|
|
448
|
+
/>
|
|
449
|
+
</NavProvider>
|
|
450
|
+
</PrimitivesProvider>
|
|
451
|
+
</DispatcherProvider>
|
|
452
|
+
</LocaleProvider>,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
|
|
456
|
+
expect(rtlScreen.getByTestId("action-adjust-rent-warn-wired-2733-item-7")).toBeTruthy();
|
|
457
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
458
|
+
} finally {
|
|
459
|
+
warnSpy.mockRestore();
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// A DataTable stub that renders one row per query row (DOM order == passed
|
|
465
|
+
// `rows` order, so a re-sort is observable as a re-ordered row list) plus a
|
|
466
|
+
// button that calls `onSortChange` the same way a real SortableHeader click
|
|
467
|
+
// would — enough to prove RelatedListSection actually re-orders rows, not
|
|
468
|
+
// just that a header is clickable.
|
|
469
|
+
const orderedDataTable: ComponentType<DataTableProps> = ({ rows, onSortChange }) => (
|
|
470
|
+
<div>
|
|
471
|
+
<table>
|
|
472
|
+
<tbody>
|
|
473
|
+
{rows.map((row) => (
|
|
474
|
+
<tr key={row.id} data-testid={`row-${row.id}`} />
|
|
475
|
+
))}
|
|
476
|
+
</tbody>
|
|
477
|
+
</table>
|
|
478
|
+
{onSortChange !== undefined && (
|
|
479
|
+
<button
|
|
480
|
+
type="button"
|
|
481
|
+
data-testid="sort-amount-desc"
|
|
482
|
+
onClick={() => onSortChange({ field: "amount", dir: "desc" })}
|
|
483
|
+
>
|
|
484
|
+
sort
|
|
485
|
+
</button>
|
|
486
|
+
)}
|
|
487
|
+
</div>
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
function renderedRowOrder(): string[] {
|
|
491
|
+
return rtlScreen
|
|
492
|
+
.getAllByTestId(/^row-/)
|
|
493
|
+
.map((el) => el.getAttribute("data-testid")?.replace("row-", "") ?? "");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
describe("RelatedListSection — sorting (fw#2722)", () => {
|
|
497
|
+
const unsortedRows = [
|
|
498
|
+
{ id: "r1", name: "Charlie", amount: 300 },
|
|
499
|
+
{ id: "r2", name: "Alice", amount: 100 },
|
|
500
|
+
{ id: "r3", name: "Bob", amount: 200 },
|
|
501
|
+
];
|
|
502
|
+
|
|
503
|
+
test("defaultSort sorts the already-loaded rows client-side, independent of query response order", async () => {
|
|
504
|
+
const { dispatcher } = stubDispatcher(unsortedRows);
|
|
505
|
+
render(
|
|
506
|
+
<LocaleProvider
|
|
507
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
508
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
509
|
+
>
|
|
510
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
511
|
+
<PrimitivesProvider value={{ ...testPrimitives(), DataTable: orderedDataTable }}>
|
|
512
|
+
<NavProvider value={stubNav().nav}>
|
|
513
|
+
<RelatedListSection
|
|
514
|
+
section={{
|
|
515
|
+
kind: "relatedList",
|
|
516
|
+
title: "Positions",
|
|
517
|
+
query: "lease:query:items:list",
|
|
518
|
+
columns: [{ field: "amount", sortable: true }],
|
|
519
|
+
defaultSort: { field: "amount", dir: "asc" },
|
|
520
|
+
}}
|
|
521
|
+
parentId="order-1"
|
|
522
|
+
featureName="orders"
|
|
523
|
+
/>
|
|
524
|
+
</NavProvider>
|
|
525
|
+
</PrimitivesProvider>
|
|
526
|
+
</DispatcherProvider>
|
|
527
|
+
</LocaleProvider>,
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
531
|
+
expect(renderedRowOrder()).toEqual(["r2", "r3", "r1"]);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("toggling sort via onSortChange re-orders rows without a refetch", async () => {
|
|
535
|
+
const { dispatcher, queryCount } = stubDispatcher(unsortedRows);
|
|
536
|
+
render(
|
|
537
|
+
<LocaleProvider
|
|
538
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
539
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
540
|
+
>
|
|
541
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
542
|
+
<PrimitivesProvider value={{ ...testPrimitives(), DataTable: orderedDataTable }}>
|
|
543
|
+
<NavProvider value={stubNav().nav}>
|
|
544
|
+
<RelatedListSection
|
|
545
|
+
section={{
|
|
546
|
+
kind: "relatedList",
|
|
547
|
+
title: "Positions",
|
|
548
|
+
query: "lease:query:items:list",
|
|
549
|
+
columns: [{ field: "amount", sortable: true }],
|
|
550
|
+
}}
|
|
551
|
+
parentId="order-1"
|
|
552
|
+
featureName="orders"
|
|
553
|
+
/>
|
|
554
|
+
</NavProvider>
|
|
555
|
+
</PrimitivesProvider>
|
|
556
|
+
</DispatcherProvider>
|
|
557
|
+
</LocaleProvider>,
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
561
|
+
expect(renderedRowOrder()).toEqual(["r1", "r2", "r3"]);
|
|
562
|
+
|
|
563
|
+
fireEvent.click(rtlScreen.getByTestId("sort-amount-desc"));
|
|
564
|
+
|
|
565
|
+
await waitFor(() => expect(renderedRowOrder()).toEqual(["r1", "r3", "r2"]));
|
|
566
|
+
expect(queryCount()).toBe(1);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("a section without defaultSort renders rows in the original query order (unchanged behavior)", async () => {
|
|
570
|
+
const { dispatcher } = stubDispatcher(unsortedRows);
|
|
571
|
+
render(
|
|
572
|
+
<LocaleProvider
|
|
573
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
574
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
575
|
+
>
|
|
576
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
577
|
+
<PrimitivesProvider value={{ ...testPrimitives(), DataTable: orderedDataTable }}>
|
|
578
|
+
<NavProvider value={stubNav().nav}>
|
|
579
|
+
<RelatedListSection
|
|
580
|
+
section={{
|
|
581
|
+
kind: "relatedList",
|
|
582
|
+
title: "Positions",
|
|
583
|
+
query: "lease:query:items:list",
|
|
584
|
+
columns: [{ field: "amount" }],
|
|
585
|
+
}}
|
|
586
|
+
parentId="order-1"
|
|
587
|
+
featureName="orders"
|
|
588
|
+
/>
|
|
589
|
+
</NavProvider>
|
|
590
|
+
</PrimitivesProvider>
|
|
591
|
+
</DispatcherProvider>
|
|
592
|
+
</LocaleProvider>,
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
596
|
+
expect(renderedRowOrder()).toEqual(["r1", "r2", "r3"]);
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe("RelatedListSection — truncation banner (fw#2722 review)", () => {
|
|
601
|
+
test("nextCursor !== null renders a truncation banner", async () => {
|
|
602
|
+
const { dispatcher } = stubDispatcher([{ id: "r1", name: "Alice" }], "cursor-abc");
|
|
603
|
+
renderRelatedList(dispatcher);
|
|
604
|
+
|
|
605
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
606
|
+
const banner = rtlScreen.getByTestId("related-list-truncated");
|
|
607
|
+
expect(banner).toBeTruthy();
|
|
608
|
+
// Proves {count} actually interpolates (single-brace, the renderer's own
|
|
609
|
+
// i18n.tsx interpolate() syntax) rather than rendering as literal braces.
|
|
610
|
+
expect(banner.textContent).toContain("Showing the first 1 entries.");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("nextCursor === null renders no truncation banner", async () => {
|
|
614
|
+
const { dispatcher } = stubDispatcher([{ id: "r1", name: "Alice" }], null);
|
|
615
|
+
renderRelatedList(dispatcher);
|
|
616
|
+
|
|
617
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
618
|
+
expect(rtlScreen.queryByTestId("related-list-truncated")).toBeNull();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("the banner shows regardless of an active sort — an unsorted truncated list is equally misleading", async () => {
|
|
622
|
+
const { dispatcher } = stubDispatcher(
|
|
623
|
+
[
|
|
624
|
+
{ id: "r1", name: "Charlie", amount: 300 },
|
|
625
|
+
{ id: "r2", name: "Alice", amount: 100 },
|
|
626
|
+
],
|
|
627
|
+
"cursor-abc",
|
|
628
|
+
);
|
|
629
|
+
renderRelatedList(dispatcher, {
|
|
630
|
+
kind: "relatedList",
|
|
631
|
+
title: "Positions",
|
|
632
|
+
query: "lease:query:items:list",
|
|
633
|
+
columns: [{ field: "amount", sortable: true }],
|
|
634
|
+
defaultSort: { field: "amount", dir: "asc" },
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
638
|
+
expect(rtlScreen.getByTestId("related-list-truncated")).toBeTruthy();
|
|
639
|
+
});
|
|
316
640
|
});
|
|
@@ -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
|
+
});
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
ListRowViewModel,
|
|
11
11
|
Translate,
|
|
12
12
|
} from "@cosmicdrift/kumiko-headless";
|
|
13
|
-
import { type ReactNode, useMemo } from "react";
|
|
13
|
+
import { type ReactNode, useMemo, useState } from "react";
|
|
14
14
|
import { useNav } from "../app/nav";
|
|
15
15
|
import {
|
|
16
16
|
buildProjectionRowActions,
|
|
@@ -19,9 +19,11 @@ import {
|
|
|
19
19
|
} from "../app/row-actions";
|
|
20
20
|
import { dispatcherErrorText } from "../app/write-failed-error";
|
|
21
21
|
import { useOptionalDispatcher } from "../context/dispatcher-context";
|
|
22
|
+
import type { ListSort } from "../hooks/use-list-url-state";
|
|
22
23
|
import { useQuery } from "../hooks/use-query";
|
|
23
24
|
import { useTranslation } from "../i18n";
|
|
24
25
|
import { usePrimitives } from "../primitives";
|
|
26
|
+
import { sortByAccessor } from "../sort-by-accessor";
|
|
25
27
|
import { RenderList } from "./render-list";
|
|
26
28
|
|
|
27
29
|
const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
|
|
@@ -36,14 +38,16 @@ type PagedRows = {
|
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
// Minimal EntityDefinition from the section's own columns — same shape as
|
|
39
|
-
// projection-list-shim's synthesizeProjectionEntity, but
|
|
40
|
-
//
|
|
41
|
+
// projection-list-shim's synthesizeProjectionEntity, but sortable is read
|
|
42
|
+
// per-column (ListColumnSpec.sortable) instead of screen-wide, since a
|
|
43
|
+
// relatedList section has no single Zod schema to derive it from.
|
|
41
44
|
function synthesizeRelatedListEntity(
|
|
42
45
|
columns: EditRelatedListSectionViewModel["columns"],
|
|
43
46
|
): EntityDefinition {
|
|
44
|
-
const fields: Record<string, { type: "text" }> = {};
|
|
47
|
+
const fields: Record<string, { type: "text"; sortable: boolean }> = {};
|
|
45
48
|
for (const col of columns) {
|
|
46
|
-
|
|
49
|
+
const normalized = normalizeListColumn(col);
|
|
50
|
+
fields[normalized.field] = { type: "text", sortable: normalized.sortable === true };
|
|
47
51
|
}
|
|
48
52
|
return { fields } as unknown as EntityDefinition;
|
|
49
53
|
}
|
|
@@ -69,7 +73,7 @@ export function RelatedListSection({
|
|
|
69
73
|
initialValues: Readonly<Record<string, unknown>> | undefined,
|
|
70
74
|
) => void;
|
|
71
75
|
}): ReactNode {
|
|
72
|
-
const { Banner, Section } = usePrimitives();
|
|
76
|
+
const { Banner, Section, FillContainer } = usePrimitives();
|
|
73
77
|
const t = useTranslation();
|
|
74
78
|
const effectiveTranslate = translate ?? t;
|
|
75
79
|
const nav = useNav();
|
|
@@ -100,6 +104,28 @@ export function RelatedListSection({
|
|
|
100
104
|
|
|
101
105
|
const rowsQuery = useQuery<PagedRows>(section.query, payload);
|
|
102
106
|
|
|
107
|
+
// Sorted client-side over the already-loaded rows — this section has no
|
|
108
|
+
// pager (see `payload` above: a one-shot fetch, no cursor/offset), so the
|
|
109
|
+
// loaded set already IS the full display set and there is no "other page"
|
|
110
|
+
// a client-side sort could misleadingly hide (fw#2722).
|
|
111
|
+
const [sort, setSort] = useState<ListSort | null>(section.defaultSort ?? null);
|
|
112
|
+
const sortAccessors = useMemo(() => {
|
|
113
|
+
const accessors: Record<string, (row: Readonly<Record<string, unknown>>) => string | number> =
|
|
114
|
+
{};
|
|
115
|
+
for (const col of section.columns) {
|
|
116
|
+
const field = normalizeListColumn(col).field;
|
|
117
|
+
accessors[field] = (row) => {
|
|
118
|
+
const value = row[field];
|
|
119
|
+
return typeof value === "number" ? value : String(value ?? "");
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return accessors;
|
|
123
|
+
}, [section.columns]);
|
|
124
|
+
const sortedRows = useMemo(
|
|
125
|
+
() => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors),
|
|
126
|
+
[rowsQuery.data, sort, sortAccessors],
|
|
127
|
+
);
|
|
128
|
+
|
|
103
129
|
const rowClick = section.rowClick;
|
|
104
130
|
// A row-body click target comes from EITHER the legacy `rowClick` field OR
|
|
105
131
|
// a `rowActions` entry marked rowClick:true — the boot-validator rejects
|
|
@@ -136,6 +162,14 @@ export function RelatedListSection({
|
|
|
136
162
|
);
|
|
137
163
|
const rowActionMode = rowActionModeFor(rowActions);
|
|
138
164
|
|
|
165
|
+
// A truncated fetch means `sortedRows` is a sort of a partial set, not of
|
|
166
|
+
// the full related-row set — the client-side sort above (or even plain
|
|
167
|
+
// unsorted display) would silently claim "these are the top N" when they
|
|
168
|
+
// are really just "the first N in the server's own order" (fw#2722 review).
|
|
169
|
+
// `nextCursor` is exactly how the paged envelope marks that: non-null means
|
|
170
|
+
// more rows exist server-side beyond what was fetched.
|
|
171
|
+
const truncated = rowsQuery.data !== null && rowsQuery.data.nextCursor !== null;
|
|
172
|
+
|
|
139
173
|
const content =
|
|
140
174
|
rowsQuery.loading && rowsQuery.data === null ? (
|
|
141
175
|
<Banner padded variant="loading" testId="related-list-loading">
|
|
@@ -146,24 +180,47 @@ export function RelatedListSection({
|
|
|
146
180
|
{dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
|
|
147
181
|
</Banner>
|
|
148
182
|
) : (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
183
|
+
<>
|
|
184
|
+
{truncated && (
|
|
185
|
+
<Banner variant="info" testId="related-list-truncated">
|
|
186
|
+
{effectiveTranslate("kumiko.list.related-list-truncated", {
|
|
187
|
+
count: sortedRows.length,
|
|
188
|
+
})}
|
|
189
|
+
</Banner>
|
|
190
|
+
)}
|
|
191
|
+
<RenderList
|
|
192
|
+
screen={listScreen}
|
|
193
|
+
entity={entity}
|
|
194
|
+
rows={sortedRows}
|
|
195
|
+
featureName={featureName}
|
|
196
|
+
translate={effectiveTranslate}
|
|
197
|
+
sort={sort}
|
|
198
|
+
onSortChange={setSort}
|
|
199
|
+
{...(onRowClick !== undefined && { onRowClick })}
|
|
200
|
+
{...(rowActions !== undefined && { rowActions })}
|
|
201
|
+
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
202
|
+
{...(hideTitle === true && { chromeless: true, scrollBody: true })}
|
|
203
|
+
/>
|
|
204
|
+
</>
|
|
159
205
|
);
|
|
160
206
|
|
|
161
|
-
// hideTitle (tabs mode) →
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
207
|
+
// hideTitle (tabs mode) → the tab panel is already the boundary: no
|
|
208
|
+
// Section card wrapper here, `chromeless` above drops the table's own
|
|
209
|
+
// card frame too, and `scrollBody` fills this wrapper's height so a long
|
|
210
|
+
// Akte tab scrolls internally instead of stretching the page (fw#2722) —
|
|
211
|
+
// the list sits directly in the tab. `FillContainer` is this section's
|
|
212
|
+
// link in RenderEdit's `fillHeight` chain (see render-edit.tsx): it is
|
|
213
|
+
// always this section's own root whenever hideTitle is set, since tabs
|
|
214
|
+
// mode narrows RenderEdit to exactly this one active section. A platform
|
|
215
|
+
// primitive (not a raw `<div>`) because `renderer` stays DOM-free —
|
|
216
|
+
// `Section`/`Card` were rejected for this spot in favor of a dedicated
|
|
217
|
+
// chromeless primitive; see `FillContainerProps` in primitives.tsx.
|
|
218
|
+
// Stacked (non-tabs) sections keep the card frame and document-flow
|
|
219
|
+
// height since they render a visible title and aren't confined to a tab
|
|
220
|
+
// panel.
|
|
221
|
+
if (hideTitle) {
|
|
222
|
+
return FillContainer !== undefined ? <FillContainer>{content}</FillContainer> : content;
|
|
223
|
+
}
|
|
167
224
|
|
|
168
225
|
return (
|
|
169
226
|
<Section title={section.title} testId={`related-list-${section.title}`}>
|
|
@@ -628,6 +628,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
628
628
|
// true when editable fields exist or an extension opted into composed submit (fw#2359).
|
|
629
629
|
const isFormEditable = hasEditableSection(filteredSections);
|
|
630
630
|
|
|
631
|
+
// A lone relatedList tab (hideSectionTitles is only ever set by the tabs
|
|
632
|
+
// layout, which also narrows filteredSections to that one active section)
|
|
633
|
+
// needs its own tab panel to fill the available height so its table
|
|
634
|
+
// scrolls inside the panel instead of the whole page stretching to the
|
|
635
|
+
// row count (fw#2722). Any other layout — multiple sections, a non-
|
|
636
|
+
// relatedList tab, stacked (non-tabs) forms — keeps normal document-flow
|
|
637
|
+
// height untouched.
|
|
638
|
+
const fillHeight = hideSectionTitles === true && filteredSections[0]?.kind === "relatedList";
|
|
639
|
+
|
|
631
640
|
// Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
|
|
632
641
|
// false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
|
|
633
642
|
// Entity-Kontext (create-mode ohne route-id) gibt es nichts zu schreiben.
|
|
@@ -1036,7 +1045,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1036
1045
|
// Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
|
|
1037
1046
|
// screen:<id>.<mode>.title / .<mode>.subtitle
|
|
1038
1047
|
// Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
|
|
1039
|
-
// title falls back to screenId; subtitle
|
|
1048
|
+
// title falls back to screenId; subtitle falls back to screen.description,
|
|
1049
|
+
// itself run through translate() so an i18n key (the established
|
|
1050
|
+
// convention for description, e.g. bundled-features) resolves instead of
|
|
1051
|
+
// showing the raw key — translate() returns the input unchanged for plain
|
|
1052
|
+
// prose, same as it does for an unknown key (fw#2723 review). Same slot
|
|
1053
|
+
// section.description already fills for a section. Falls back to
|
|
1054
|
+
// undefined (no subtitle) when neither is set.
|
|
1040
1055
|
const isCreate = (() => {
|
|
1041
1056
|
const id = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
1042
1057
|
return id == null || id === "";
|
|
@@ -1053,7 +1068,9 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1053
1068
|
return undefined;
|
|
1054
1069
|
};
|
|
1055
1070
|
const formTitle = resolveScreenText("title") ?? screen.id;
|
|
1056
|
-
const formSubtitle =
|
|
1071
|
+
const formSubtitle =
|
|
1072
|
+
resolveScreenText("subtitle") ??
|
|
1073
|
+
(screen.description !== undefined ? translate(screen.description) : undefined);
|
|
1057
1074
|
|
|
1058
1075
|
return (
|
|
1059
1076
|
<ExtensionFormRegistryProvider value={extensionFormRegistry}>
|
|
@@ -1069,6 +1086,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1069
1086
|
stickyActions={isWizard}
|
|
1070
1087
|
{...(screen.layout.width !== undefined && { width: screen.layout.width })}
|
|
1071
1088
|
{...(headerRegion !== undefined && { headerRegion })}
|
|
1089
|
+
{...(fillHeight && { fillHeight })}
|
|
1072
1090
|
>
|
|
1073
1091
|
{draftCandidates !== null && (
|
|
1074
1092
|
<Banner
|
|
@@ -110,6 +110,14 @@ 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;
|
|
117
|
+
/** Forwarded to `DataTableProps.scrollBody` — bounds the table height and
|
|
118
|
+
* scrolls rows internally instead of growing the page (relatedList in a
|
|
119
|
+
* tabs-mode section, fw#2722). Default false. */
|
|
120
|
+
readonly scrollBody?: boolean;
|
|
113
121
|
};
|
|
114
122
|
|
|
115
123
|
// Resolved-Form einer Toolbar-Action: KumikoScreen baut das aus dem
|
|
@@ -158,6 +166,8 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
158
166
|
filterValues,
|
|
159
167
|
onFilterChange,
|
|
160
168
|
onFilterReset,
|
|
169
|
+
chromeless,
|
|
170
|
+
scrollBody,
|
|
161
171
|
} = props;
|
|
162
172
|
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
163
173
|
// wären Column-Header raw i18n-Keys.
|
|
@@ -372,6 +382,8 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
372
382
|
{...(filterValues !== undefined && { filterValues })}
|
|
373
383
|
{...(onFilterChange !== undefined && { onFilterChange })}
|
|
374
384
|
{...(onFilterReset !== undefined && { onFilterReset })}
|
|
385
|
+
{...(chromeless !== undefined && { chromeless })}
|
|
386
|
+
{...(scrollBody !== undefined && { scrollBody })}
|
|
375
387
|
testId="render-list-table"
|
|
376
388
|
/>
|
|
377
389
|
</>
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -64,6 +64,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
64
64
|
"kumiko.list.end-of-list": "— End of list —",
|
|
65
65
|
"kumiko.list.sort.label": "Sort",
|
|
66
66
|
"kumiko.list.sort.unsorted": "Unsorted",
|
|
67
|
+
"kumiko.list.related-list-truncated":
|
|
68
|
+
"Showing the first {count} entries. More are available but not loaded — this list does not paginate.",
|
|
67
69
|
"kumiko.reference.system-tenant": "System",
|
|
68
70
|
|
|
69
71
|
"kumiko.pager.status": "{from}–{to} of {total}",
|
package/src/index.ts
CHANGED
package/src/primitives.tsx
CHANGED
|
@@ -618,6 +618,20 @@ 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;
|
|
626
|
+
/** Fills the available height of its flex container and scrolls rows
|
|
627
|
+
* internally instead of growing with row count — for a host that would
|
|
628
|
+
* otherwise have a long table stretch the whole page, or a short table
|
|
629
|
+
* leave dead space below it (a tab panel, fw#2722). Requires the same
|
|
630
|
+
* flex-fill chain FormProps.fillHeight sets up above it; without that
|
|
631
|
+
* ancestor chain this collapses to zero height (Web: `flex-1 min-h-0`
|
|
632
|
+
* has no effect outside a sized flex-col ancestor). Default false:
|
|
633
|
+
* unchanged document-flow table that grows with its content. */
|
|
634
|
+
readonly scrollBody?: boolean;
|
|
621
635
|
};
|
|
622
636
|
|
|
623
637
|
// ---- EmbeddedListInput (createEmbeddedListField widget) ----
|
|
@@ -750,6 +764,14 @@ export type FormProps = {
|
|
|
750
764
|
* instead of rendering as unpadded siblings before it. Native impls may
|
|
751
765
|
* ignore this prop. */
|
|
752
766
|
readonly headerRegion?: ReactNode;
|
|
767
|
+
/** Sizes the form to fill its container's height (instead of the page's
|
|
768
|
+
* natural content height) so a single scrolling child — a lone
|
|
769
|
+
* relatedList tab's table — can scroll internally instead of stretching
|
|
770
|
+
* the whole page (fw#2722). Only set by RenderEdit for a lone relatedList
|
|
771
|
+
* tab section; every other caller leaves it unset and keeps normal
|
|
772
|
+
* document-flow height. Native impls may ignore this prop (already a
|
|
773
|
+
* bounded viewport there). */
|
|
774
|
+
readonly fillHeight?: boolean;
|
|
753
775
|
};
|
|
754
776
|
|
|
755
777
|
/** Titled Gruppe von Feldern. Web: `<fieldset>` + `<legend>`, Native:
|
|
@@ -779,6 +801,21 @@ export type SectionProps = {
|
|
|
779
801
|
readonly icon?: IconKey;
|
|
780
802
|
};
|
|
781
803
|
|
|
804
|
+
/** Chromeless flex-fill layout host — no title, no card frame, no padding,
|
|
805
|
+
* just a container that sizes to fill its parent and lets one scrolling
|
|
806
|
+
* child scroll internally instead of the page growing (fw#2722). Web:
|
|
807
|
+
* `<div className="flex flex-1 min-h-0 flex-col">`. Native: Views are
|
|
808
|
+
* already flex-column and the parent is already a bounded viewport there,
|
|
809
|
+
* so a native impl may render this as a bare Fragment. Used only as the
|
|
810
|
+
* terminal link in `RenderEdit`'s `fillHeight` chain (`RelatedListSection`'s
|
|
811
|
+
* `hideTitle` branch) — `Section`/`Card` were rejected here because both
|
|
812
|
+
* carry title/padding/border chrome this spot doesn't want and would double
|
|
813
|
+
* up with the table's own padding. */
|
|
814
|
+
export type FillContainerProps = {
|
|
815
|
+
readonly children: ReactNode;
|
|
816
|
+
readonly testId?: string;
|
|
817
|
+
};
|
|
818
|
+
|
|
782
819
|
/** Columns-basiertes Layout. Web: CSS grid, Native: Flex-Wrap mit
|
|
783
820
|
* Width-%, oder react-native-grid. Jedes direkte Child kann eine
|
|
784
821
|
* `GridCell`-Wrapping bekommen für span-Kontrolle.
|
|
@@ -1134,6 +1171,10 @@ export type CorePrimitives = {
|
|
|
1134
1171
|
* CorePrimitives mocks in tests keep compiling — additive rollout of
|
|
1135
1172
|
* a new primitive shouldn't force every test double to grow a stub. */
|
|
1136
1173
|
readonly JsonView?: ComponentType<JsonViewProps>;
|
|
1174
|
+
/** Optional (unlike the other Core-Primitives) so existing partial
|
|
1175
|
+
* CorePrimitives mocks in tests keep compiling — additive rollout of
|
|
1176
|
+
* a new primitive shouldn't force every test double to grow a stub. */
|
|
1177
|
+
readonly FillContainer?: ComponentType<FillContainerProps>;
|
|
1137
1178
|
};
|
|
1138
1179
|
|
|
1139
1180
|
/** Offene Extension-Zone für App-eigene Primitives. Devs erweitern
|