@cosmicdrift/kumiko-renderer-web 0.232.0 → 0.234.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/__tests__/app-layout.test.tsx +13 -12
- package/src/__tests__/default-app-shell.test.tsx +33 -0
- package/src/__tests__/form-action-bar.test.tsx +53 -4
- package/src/__tests__/kumiko-screen.test.tsx +293 -3
- package/src/__tests__/primitives.test.tsx +245 -15
- package/src/__tests__/projection-detail-record-layout.test.tsx +91 -0
- package/src/__tests__/render-edit.test.tsx +29 -10
- package/src/__tests__/render-list-narrow-cards.test.tsx +187 -0
- package/src/__tests__/workspace-shell.test.tsx +5 -4
- package/src/icons.tsx +199 -0
- package/src/layout/app-layout.tsx +7 -7
- package/src/layout/default-app-shell.tsx +3 -2
- package/src/layout/nav-tree.tsx +25 -122
- package/src/layout/workspace-shell.tsx +3 -2
- package/src/primitives/__tests__/button.test.tsx +37 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +33 -0
- package/src/primitives/__tests__/select-segmented.test.tsx +84 -0
- package/src/primitives/index.tsx +475 -50
- package/src/primitives/metric.tsx +18 -10
- package/src/styles.css +21 -17
- package/src/ui/switch.tsx +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.234.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.234.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.234.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.234.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"jsdom": "^29.1.1",
|
|
66
66
|
"tailwindcss": "^4.3.0",
|
|
67
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.234.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// AppLayout: pinnt den fill-Vertrag — Default ist
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// AppLayout: pinnt den fill-Vertrag — Default ist die viewport-hohe
|
|
2
|
+
// Shell (h-screen, main scrollt innen via min-h-0), fill={false} ist der
|
|
3
|
+
// Escape-Hatch auf den klassischen min-h-screen-Seitenflow.
|
|
4
|
+
// Plus die className/mainClassName-Erweiterungspunkte.
|
|
4
5
|
|
|
5
6
|
import { describe, expect, test } from "bun:test";
|
|
6
7
|
import { AppLayout } from "../layout/app-layout";
|
|
@@ -13,23 +14,23 @@ function root(container: HTMLElement): HTMLElement {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
describe("AppLayout fill", () => {
|
|
16
|
-
test("Default → min-h-
|
|
17
|
+
test("Default → h-screen + main min-h-0 (Innen-Scroll), fill-Marker gesetzt", () => {
|
|
17
18
|
const { container } = render(<AppLayout>x</AppLayout>);
|
|
18
19
|
const el = root(container);
|
|
19
|
-
expect(el.className).toContain("min-h-screen");
|
|
20
|
-
expect(el.getAttribute("data-kumiko-fill")).toBeNull();
|
|
21
|
-
expect((container.querySelector("main") as HTMLElement).className).not.toContain("min-h-0");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("fill → h-screen + main min-h-0 (Innen-Scroll), fill-Marker gesetzt", () => {
|
|
25
|
-
const { container } = render(<AppLayout fill>x</AppLayout>);
|
|
26
|
-
const el = root(container);
|
|
27
20
|
expect(el.className).not.toContain("min-h-screen");
|
|
28
21
|
expect(el.className).toContain("h-screen");
|
|
29
22
|
expect(el.getAttribute("data-kumiko-fill")).toBe("true");
|
|
30
23
|
expect((container.querySelector("main") as HTMLElement).className).toContain("min-h-0");
|
|
31
24
|
});
|
|
32
25
|
|
|
26
|
+
test("fill={false} → min-h-screen (Seiten-Scroll), kein fill-Marker (Escape-Hatch)", () => {
|
|
27
|
+
const { container } = render(<AppLayout fill={false}>x</AppLayout>);
|
|
28
|
+
const el = root(container);
|
|
29
|
+
expect(el.className).toContain("min-h-screen");
|
|
30
|
+
expect(el.getAttribute("data-kumiko-fill")).toBeNull();
|
|
31
|
+
expect((container.querySelector("main") as HTMLElement).className).not.toContain("min-h-0");
|
|
32
|
+
});
|
|
33
|
+
|
|
33
34
|
test("className/mainClassName werden an die Defaults angehängt", () => {
|
|
34
35
|
const { container } = render(
|
|
35
36
|
<AppLayout className="bg-gradient-test" mainClassName="main-test">
|
|
@@ -130,3 +130,36 @@ describe("DefaultAppShell wires user.roles into children for screen-level access
|
|
|
130
130
|
expect(screen.queryByTestId("kumiko-screen-access-denied")).toBeNull();
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
|
+
|
|
134
|
+
describe("DefaultAppShell fill default", () => {
|
|
135
|
+
test("fill defaults to true: applies h-svh on root and min-h-0 on inset/main", () => {
|
|
136
|
+
render(
|
|
137
|
+
<DefaultAppShell brand={<span>Brand</span>} schema={makeSchema()}>
|
|
138
|
+
<div>content</div>
|
|
139
|
+
</DefaultAppShell>,
|
|
140
|
+
);
|
|
141
|
+
const root = document.querySelector('[data-slot="sidebar-wrapper"]');
|
|
142
|
+
expect(root?.classList.contains("h-svh")).toBe(true);
|
|
143
|
+
const inset = document.querySelector('[data-slot="sidebar-inset"]');
|
|
144
|
+
expect(inset?.classList.contains("min-h-0")).toBe(true);
|
|
145
|
+
const mains = screen.getAllByRole("main");
|
|
146
|
+
const innerMain = mains.find((m) => m !== inset);
|
|
147
|
+
expect(innerMain?.classList.contains("min-h-0")).toBe(true);
|
|
148
|
+
expect(innerMain?.classList.contains("flex-1")).toBe(true);
|
|
149
|
+
expect(innerMain?.classList.contains("overflow-auto")).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// fill={false} is the escape hatch back to page-scroll: must not apply
|
|
153
|
+
// viewport-lock classes.
|
|
154
|
+
test("fill={false} does not apply h-svh or min-h-0", () => {
|
|
155
|
+
render(
|
|
156
|
+
<DefaultAppShell brand={<span>Brand</span>} schema={makeSchema()} fill={false}>
|
|
157
|
+
<div>content</div>
|
|
158
|
+
</DefaultAppShell>,
|
|
159
|
+
);
|
|
160
|
+
const root = document.querySelector('[data-slot="sidebar-wrapper"]');
|
|
161
|
+
expect(root?.classList.contains("h-svh")).toBe(false);
|
|
162
|
+
const inset = document.querySelector('[data-slot="sidebar-inset"]');
|
|
163
|
+
expect(inset?.classList.contains("min-h-0")).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -15,21 +15,70 @@ import { createMockDispatcher, render, screen } from "./test-utils";
|
|
|
15
15
|
const { Form, Section, Button } = defaultPrimitives;
|
|
16
16
|
|
|
17
17
|
describe("DefaultForm Action-Footer", () => {
|
|
18
|
-
test("Actions sitzen
|
|
18
|
+
test("Actions sitzen am Form-Ende (border-t, sm:justify-between), Titel ist Heading", () => {
|
|
19
19
|
render(
|
|
20
20
|
<Form onSubmit={() => {}} title="Titel" actions={<Button>Save</Button>} testId="f">
|
|
21
21
|
<div>body</div>
|
|
22
22
|
</Form>,
|
|
23
23
|
);
|
|
24
24
|
const actions = screen.getByTestId("f-actions");
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// The border/layout classes live on the outer footer container (which
|
|
26
|
+
// also wraps the optional secondaryActions group), not on the inner
|
|
27
|
+
// `-actions` div itself.
|
|
28
|
+
const footer = actions.parentElement as HTMLElement;
|
|
29
|
+
expect(footer.className).toContain("sm:justify-between");
|
|
30
|
+
expect(footer.className).toContain("border-t");
|
|
27
31
|
// Footer sits in the form shell (buttons align with the field edge).
|
|
28
|
-
expect(actions.closest(".max-w-
|
|
32
|
+
expect(actions.closest(".max-w-4xl")).toBeTruthy();
|
|
29
33
|
// Title is its own heading above — not inside the action bar.
|
|
30
34
|
expect(actions.textContent).not.toContain("Titel");
|
|
31
35
|
expect(screen.getByTestId("f-title").textContent).toBe("Titel");
|
|
32
36
|
});
|
|
37
|
+
|
|
38
|
+
test("secondaryActions + actions: zwei Gruppen mit eigenen testIds, Container flex-col-reverse", () => {
|
|
39
|
+
render(
|
|
40
|
+
<Form
|
|
41
|
+
onSubmit={() => {}}
|
|
42
|
+
actions={<Button>Save</Button>}
|
|
43
|
+
secondaryActions={<Button variant="danger-ghost">Delete</Button>}
|
|
44
|
+
testId="f"
|
|
45
|
+
>
|
|
46
|
+
<div>body</div>
|
|
47
|
+
</Form>,
|
|
48
|
+
);
|
|
49
|
+
const secondary = screen.getByTestId("f-actions-secondary");
|
|
50
|
+
const main = screen.getByTestId("f-actions");
|
|
51
|
+
expect(secondary.textContent).toBe("Delete");
|
|
52
|
+
expect(main.textContent).toBe("Save");
|
|
53
|
+
// Same outer footer container wraps both groups.
|
|
54
|
+
expect(secondary.parentElement).toBe(main.parentElement);
|
|
55
|
+
const footer = main.parentElement as HTMLElement;
|
|
56
|
+
expect(footer.className).toContain("flex-col-reverse");
|
|
57
|
+
expect(footer.className).toContain("sm:justify-between");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("nur actions (keine secondaryActions): keine -actions-secondary-Gruppe", () => {
|
|
61
|
+
render(
|
|
62
|
+
<Form onSubmit={() => {}} actions={<Button>Save</Button>} testId="f">
|
|
63
|
+
<div>body</div>
|
|
64
|
+
</Form>,
|
|
65
|
+
);
|
|
66
|
+
expect(screen.queryByTestId("f-actions-secondary")).toBeNull();
|
|
67
|
+
expect(screen.getByTestId("f-actions").textContent).toBe("Save");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("nur actions (keine secondaryActions): Gruppe bleibt rechtsbündig (sm:ml-auto)", () => {
|
|
71
|
+
render(
|
|
72
|
+
<Form onSubmit={() => {}} actions={<Button>Save</Button>} testId="f">
|
|
73
|
+
<div>body</div>
|
|
74
|
+
</Form>,
|
|
75
|
+
);
|
|
76
|
+
// justify-between on the footer only pushes the lone group to the edge
|
|
77
|
+
// when there's a second flex child to push away from — with a single
|
|
78
|
+
// child it falls back to flex-start, so the actions group needs its own
|
|
79
|
+
// margin to stay right-aligned on desktop.
|
|
80
|
+
expect(screen.getByTestId("f-actions").className).toContain("sm:ml-auto");
|
|
81
|
+
});
|
|
33
82
|
});
|
|
34
83
|
|
|
35
84
|
describe("DefaultSection ohne Titel", () => {
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
EntityDefinition,
|
|
5
5
|
EntityEditScreenDefinition,
|
|
6
6
|
EntityListScreenDefinition,
|
|
7
|
+
ProjectionListScreenDefinition,
|
|
7
8
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
9
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
9
10
|
import type { FeatureSchema, NavApi, NavTarget } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -438,9 +439,9 @@ describe("KumikoScreen", () => {
|
|
|
438
439
|
// Issue #912 — Copy-Link-Action. KumikoScreen threads an already-bound
|
|
439
440
|
// onCopyLink callback down to RenderEdit (the actual URL-build/clipboard
|
|
440
441
|
// impl lives in renderer-web's RoutedScreen, outside this test — here we
|
|
441
|
-
// only verify the wiring: button renders in update-mode, fires
|
|
442
|
-
// callback, and
|
|
443
|
-
test("entityEdit update-mode: Copy-Link-Button feuert onCopyLink + zeigt 'copied'-
|
|
442
|
+
// only verify the wiring: button renders icon-only in update-mode, fires
|
|
443
|
+
// the callback, and swaps its accessible name to "copied" afterwards.
|
|
444
|
+
test("entityEdit update-mode: Copy-Link-Button feuert onCopyLink + zeigt 'copied'-Accessible-Name", async () => {
|
|
444
445
|
const user = userEvent.setup();
|
|
445
446
|
const onCopyLink = mock(() => Promise.resolve());
|
|
446
447
|
const dispatcher = makeDispatcher({
|
|
@@ -462,7 +463,9 @@ describe("KumikoScreen", () => {
|
|
|
462
463
|
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
463
464
|
|
|
464
465
|
const button = screen.getByTestId("render-edit-copy-link");
|
|
466
|
+
// Icon + visible label; accessible name comes from the label text itself.
|
|
465
467
|
expect(button.textContent).toBe("Copy link");
|
|
468
|
+
expect(button.querySelector("svg")).toBeTruthy();
|
|
466
469
|
await user.click(button);
|
|
467
470
|
expect(onCopyLink).toHaveBeenCalledTimes(1);
|
|
468
471
|
await waitFor(() => expect(button.textContent).toBe("Copied!"));
|
|
@@ -2449,3 +2452,290 @@ describe("KumikoScreen: actionForm extension-section", () => {
|
|
|
2449
2452
|
expect(screen.getByTestId("update-timeline").textContent).toBe("inc-7");
|
|
2450
2453
|
});
|
|
2451
2454
|
});
|
|
2455
|
+
|
|
2456
|
+
// --- entityEdit header actions (fw entityEdit-actions) ---
|
|
2457
|
+
// EntityEditScreenDefinition.actions mirrors ProjectionDetailScreenDefinition.
|
|
2458
|
+
// actions — same RenderEdit `actions` prop, same icon-only collapse rule
|
|
2459
|
+
// (shouldRenderActionsIconOnly: >2 actions, every one resolves an icon).
|
|
2460
|
+
describe("KumikoScreen: entityEdit header actions", () => {
|
|
2461
|
+
const threeActionsScreen: EntityEditScreenDefinition = {
|
|
2462
|
+
id: "task-edit-actions-3",
|
|
2463
|
+
type: "entityEdit",
|
|
2464
|
+
entity: "task",
|
|
2465
|
+
layout: { sections: [{ title: "Basics", fields: ["title"] }] },
|
|
2466
|
+
// ids match ACTION_ICON_BY_ID entries (publish/archive/duplicate) so
|
|
2467
|
+
// every action resolves an icon without an explicit `icon` field.
|
|
2468
|
+
actions: [
|
|
2469
|
+
{ id: "publish", label: "Publish", handler: "tasks:write:task:publish" },
|
|
2470
|
+
{ id: "archive", label: "Archive", handler: "tasks:write:task:archive" },
|
|
2471
|
+
{ id: "duplicate", label: "Duplicate", handler: "tasks:write:task:duplicate" },
|
|
2472
|
+
],
|
|
2473
|
+
};
|
|
2474
|
+
const twoActionsScreen: EntityEditScreenDefinition = {
|
|
2475
|
+
...threeActionsScreen,
|
|
2476
|
+
id: "task-edit-actions-2",
|
|
2477
|
+
actions: threeActionsScreen.actions?.slice(0, 2),
|
|
2478
|
+
};
|
|
2479
|
+
const actionsSchema: FeatureSchema = {
|
|
2480
|
+
featureName: "tasks",
|
|
2481
|
+
entities: { task: taskEntity },
|
|
2482
|
+
screens: [threeActionsScreen, twoActionsScreen],
|
|
2483
|
+
};
|
|
2484
|
+
const detailDispatcher = () =>
|
|
2485
|
+
makeDispatcher({
|
|
2486
|
+
query: (async () => ({
|
|
2487
|
+
isSuccess: true,
|
|
2488
|
+
data: { id: "task-1", version: 1, title: "loaded", count: 0, done: false },
|
|
2489
|
+
})) as unknown as Dispatcher["query"],
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
test("update-mode mit drei Header-Actions kollabiert auf Icon-only mit ariaLabel", async () => {
|
|
2493
|
+
render(
|
|
2494
|
+
<DispatcherProvider dispatcher={detailDispatcher()}>
|
|
2495
|
+
<KumikoScreen
|
|
2496
|
+
schema={actionsSchema}
|
|
2497
|
+
qn="tasks:screen:task-edit-actions-3"
|
|
2498
|
+
entityId="task-1"
|
|
2499
|
+
/>
|
|
2500
|
+
</DispatcherProvider>,
|
|
2501
|
+
);
|
|
2502
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2503
|
+
|
|
2504
|
+
const cases = [
|
|
2505
|
+
["publish", "Publish"],
|
|
2506
|
+
["archive", "Archive"],
|
|
2507
|
+
["duplicate", "Duplicate"],
|
|
2508
|
+
] as const;
|
|
2509
|
+
for (const [id, label] of cases) {
|
|
2510
|
+
const button = screen.getByTestId(`render-edit-action-${id}`);
|
|
2511
|
+
expect(button.textContent).toBe("");
|
|
2512
|
+
expect(button.getAttribute("aria-label")).toBe(label);
|
|
2513
|
+
}
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
test("update-mode mit zwei Header-Actions zeigt weiterhin sichtbaren Text", async () => {
|
|
2517
|
+
render(
|
|
2518
|
+
<DispatcherProvider dispatcher={detailDispatcher()}>
|
|
2519
|
+
<KumikoScreen
|
|
2520
|
+
schema={actionsSchema}
|
|
2521
|
+
qn="tasks:screen:task-edit-actions-2"
|
|
2522
|
+
entityId="task-1"
|
|
2523
|
+
/>
|
|
2524
|
+
</DispatcherProvider>,
|
|
2525
|
+
);
|
|
2526
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2527
|
+
|
|
2528
|
+
expect(screen.getByTestId("render-edit-action-publish").textContent).toBe("Publish");
|
|
2529
|
+
expect(screen.getByTestId("render-edit-action-archive").textContent).toBe("Archive");
|
|
2530
|
+
});
|
|
2531
|
+
|
|
2532
|
+
test("update-mode mit drei Header-Actions kollabiert auch den Copy-Link-Button auf Icon-only mit ariaLabel", async () => {
|
|
2533
|
+
render(
|
|
2534
|
+
<DispatcherProvider dispatcher={detailDispatcher()}>
|
|
2535
|
+
<KumikoScreen
|
|
2536
|
+
schema={actionsSchema}
|
|
2537
|
+
qn="tasks:screen:task-edit-actions-3"
|
|
2538
|
+
entityId="task-1"
|
|
2539
|
+
onCopyLink={() => Promise.resolve()}
|
|
2540
|
+
/>
|
|
2541
|
+
</DispatcherProvider>,
|
|
2542
|
+
);
|
|
2543
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2544
|
+
|
|
2545
|
+
const button = screen.getByTestId("render-edit-copy-link");
|
|
2546
|
+
expect(button.textContent).toBe("");
|
|
2547
|
+
expect(button.getAttribute("aria-label")).toBe("Copy link");
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
// Design decision (fw entityEdit-actions): actions target an EXISTING
|
|
2551
|
+
// record (publish/archive/duplicate and friends) — create mode has none
|
|
2552
|
+
// yet, so EntityEditCreateBody never builds a headerActions prop at all.
|
|
2553
|
+
test("create-mode (kein entityId) rendert keine Header-Actions", () => {
|
|
2554
|
+
render(
|
|
2555
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
2556
|
+
<KumikoScreen schema={actionsSchema} qn="tasks:screen:task-edit-actions-3" />
|
|
2557
|
+
</DispatcherProvider>,
|
|
2558
|
+
);
|
|
2559
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
2560
|
+
expect(screen.queryByTestId("render-edit-action-publish")).toBeNull();
|
|
2561
|
+
expect(screen.queryByTestId("render-edit-action-archive")).toBeNull();
|
|
2562
|
+
expect(screen.queryByTestId("render-edit-action-duplicate")).toBeNull();
|
|
2563
|
+
});
|
|
2564
|
+
});
|
|
2565
|
+
|
|
2566
|
+
// --- entityList status cells + row actions (fw#2579, fw#2580) ---
|
|
2567
|
+
// Both are leftovers from #2575: the tone heuristic and the icon-only
|
|
2568
|
+
// collapse existed, but neither reached a list cell. These render the real
|
|
2569
|
+
// entityList pipeline (KumikoScreen -> EntityListBody -> RenderList ->
|
|
2570
|
+
// DataTable) against the real web primitives.
|
|
2571
|
+
describe("KumikoScreen: entityList cell rendering", () => {
|
|
2572
|
+
const orderEntity = {
|
|
2573
|
+
fields: {
|
|
2574
|
+
title: { type: "text", required: true },
|
|
2575
|
+
status: { type: "select", options: ["active", "unmapped-value", "constructor"] },
|
|
2576
|
+
},
|
|
2577
|
+
} as unknown as EntityDefinition;
|
|
2578
|
+
|
|
2579
|
+
const statusListScreen: EntityListScreenDefinition = {
|
|
2580
|
+
id: "order-list",
|
|
2581
|
+
type: "entityList",
|
|
2582
|
+
entity: "order",
|
|
2583
|
+
columns: ["title", "status"],
|
|
2584
|
+
};
|
|
2585
|
+
|
|
2586
|
+
// publish/archive/duplicate all resolve an icon from their id
|
|
2587
|
+
// (ACTION_ICON_BY_ID), which is what arms the icon-only collapse.
|
|
2588
|
+
const rowActionListScreen: EntityListScreenDefinition = {
|
|
2589
|
+
id: "order-list-actions",
|
|
2590
|
+
type: "entityList",
|
|
2591
|
+
entity: "order",
|
|
2592
|
+
columns: ["title"],
|
|
2593
|
+
rowActions: [
|
|
2594
|
+
{ id: "publish", label: "Publish", handler: "orders:write:order:publish" },
|
|
2595
|
+
{ id: "archive", label: "Archive", handler: "orders:write:order:archive" },
|
|
2596
|
+
{ id: "duplicate", label: "Duplicate", handler: "orders:write:order:duplicate" },
|
|
2597
|
+
],
|
|
2598
|
+
};
|
|
2599
|
+
|
|
2600
|
+
// "review" has no icon in ACTION_ICON_BY_ID, so this group must stay on
|
|
2601
|
+
// the adaptive default (kebab) instead of collapsing.
|
|
2602
|
+
const mixedActionListScreen: EntityListScreenDefinition = {
|
|
2603
|
+
id: "order-list-mixed",
|
|
2604
|
+
type: "entityList",
|
|
2605
|
+
entity: "order",
|
|
2606
|
+
columns: ["title"],
|
|
2607
|
+
rowActions: [
|
|
2608
|
+
{ id: "publish", label: "Publish", handler: "orders:write:order:publish" },
|
|
2609
|
+
{ id: "archive", label: "Archive", handler: "orders:write:order:archive" },
|
|
2610
|
+
{ id: "review", label: "Review", handler: "orders:write:order:review" },
|
|
2611
|
+
],
|
|
2612
|
+
};
|
|
2613
|
+
|
|
2614
|
+
// projectionList reaches the same RenderList/DataTable path, so the collapse
|
|
2615
|
+
// has to hold there too.
|
|
2616
|
+
const projectionActionListScreen: ProjectionListScreenDefinition = {
|
|
2617
|
+
id: "order-projection-actions",
|
|
2618
|
+
type: "projectionList",
|
|
2619
|
+
query: "orders:query:order:list",
|
|
2620
|
+
columns: [{ field: "title", label: "Title" }],
|
|
2621
|
+
rowActions: [
|
|
2622
|
+
{
|
|
2623
|
+
kind: "writeHandler",
|
|
2624
|
+
id: "publish",
|
|
2625
|
+
label: "Publish",
|
|
2626
|
+
handler: "orders:write:order:publish",
|
|
2627
|
+
},
|
|
2628
|
+
{
|
|
2629
|
+
kind: "writeHandler",
|
|
2630
|
+
id: "archive",
|
|
2631
|
+
label: "Archive",
|
|
2632
|
+
handler: "orders:write:order:archive",
|
|
2633
|
+
},
|
|
2634
|
+
{
|
|
2635
|
+
kind: "writeHandler",
|
|
2636
|
+
id: "duplicate",
|
|
2637
|
+
label: "Duplicate",
|
|
2638
|
+
handler: "orders:write:order:duplicate",
|
|
2639
|
+
},
|
|
2640
|
+
],
|
|
2641
|
+
};
|
|
2642
|
+
|
|
2643
|
+
const orderSchema: FeatureSchema = {
|
|
2644
|
+
featureName: "orders",
|
|
2645
|
+
entities: { order: orderEntity },
|
|
2646
|
+
screens: [
|
|
2647
|
+
statusListScreen,
|
|
2648
|
+
rowActionListScreen,
|
|
2649
|
+
mixedActionListScreen,
|
|
2650
|
+
projectionActionListScreen,
|
|
2651
|
+
],
|
|
2652
|
+
};
|
|
2653
|
+
|
|
2654
|
+
function orderDispatcher(rows: readonly Record<string, unknown>[]): Dispatcher {
|
|
2655
|
+
return makeDispatcher({
|
|
2656
|
+
query: (async () => ({
|
|
2657
|
+
isSuccess: true,
|
|
2658
|
+
data: { rows, nextCursor: null },
|
|
2659
|
+
})) as unknown as Dispatcher["query"],
|
|
2660
|
+
});
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
async function renderOrderList(
|
|
2664
|
+
qn: string,
|
|
2665
|
+
rows: readonly Record<string, unknown>[],
|
|
2666
|
+
): Promise<void> {
|
|
2667
|
+
render(
|
|
2668
|
+
<DispatcherProvider dispatcher={orderDispatcher(rows)}>
|
|
2669
|
+
<KumikoScreen schema={orderSchema} qn={qn} />
|
|
2670
|
+
</DispatcherProvider>,
|
|
2671
|
+
);
|
|
2672
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
test("a known status value renders a toned badge, not the muted default (#2579)", async () => {
|
|
2676
|
+
await renderOrderList("orders:screen:order-list", [
|
|
2677
|
+
{ id: "r1", title: "First", status: "active" },
|
|
2678
|
+
]);
|
|
2679
|
+
|
|
2680
|
+
const badge = screen.getByTestId("cell-r1-status").firstElementChild;
|
|
2681
|
+
expect(badge?.className).toContain("text-status-ok");
|
|
2682
|
+
expect(badge?.className).not.toContain("text-muted-foreground");
|
|
2683
|
+
expect(badge?.textContent).toBe("Active");
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
test("an unmapped select value keeps the neutral pill (#2579)", async () => {
|
|
2687
|
+
await renderOrderList("orders:screen:order-list", [
|
|
2688
|
+
{ id: "r1", title: "First", status: "unmapped-value" },
|
|
2689
|
+
]);
|
|
2690
|
+
|
|
2691
|
+
const badge = screen.getByTestId("cell-r1-status").firstElementChild;
|
|
2692
|
+
expect(badge?.className).toContain("text-muted-foreground");
|
|
2693
|
+
expect(badge?.className).not.toContain("text-status-ok");
|
|
2694
|
+
});
|
|
2695
|
+
|
|
2696
|
+
test("an Object.prototype key is not mistaken for a tone (#2579)", async () => {
|
|
2697
|
+
await renderOrderList("orders:screen:order-list", [
|
|
2698
|
+
{ id: "r1", title: "First", status: "constructor" },
|
|
2699
|
+
]);
|
|
2700
|
+
|
|
2701
|
+
const badge = screen.getByTestId("cell-r1-status").firstElementChild;
|
|
2702
|
+
expect(badge?.className).toContain("text-muted-foreground");
|
|
2703
|
+
});
|
|
2704
|
+
|
|
2705
|
+
test("three icon-bearing row actions collapse to icon-only buttons (#2580)", async () => {
|
|
2706
|
+
await renderOrderList("orders:screen:order-list-actions", [{ id: "r1", title: "First" }]);
|
|
2707
|
+
|
|
2708
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).toBeNull();
|
|
2709
|
+
for (const [id, label] of [
|
|
2710
|
+
["publish", "Publish"],
|
|
2711
|
+
["archive", "Archive"],
|
|
2712
|
+
["duplicate", "Duplicate"],
|
|
2713
|
+
] as const) {
|
|
2714
|
+
const button = screen.getByTestId(`row-r1-action-${id}`);
|
|
2715
|
+
expect(button.textContent).toBe("");
|
|
2716
|
+
expect(button.getAttribute("aria-label")).toBe(label);
|
|
2717
|
+
}
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
test("projectionList row actions collapse the same way (#2580)", async () => {
|
|
2721
|
+
await renderOrderList("orders:screen:order-projection-actions", [{ id: "r1", title: "First" }]);
|
|
2722
|
+
|
|
2723
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).toBeNull();
|
|
2724
|
+
for (const [id, label] of [
|
|
2725
|
+
["publish", "Publish"],
|
|
2726
|
+
["archive", "Archive"],
|
|
2727
|
+
["duplicate", "Duplicate"],
|
|
2728
|
+
] as const) {
|
|
2729
|
+
const button = screen.getByTestId(`row-r1-action-${id}`);
|
|
2730
|
+
expect(button.textContent).toBe("");
|
|
2731
|
+
expect(button.getAttribute("aria-label")).toBe(label);
|
|
2732
|
+
}
|
|
2733
|
+
});
|
|
2734
|
+
|
|
2735
|
+
test("a row-action group with an icon-less member stays on the kebab (#2580)", async () => {
|
|
2736
|
+
await renderOrderList("orders:screen:order-list-mixed", [{ id: "r1", title: "First" }]);
|
|
2737
|
+
|
|
2738
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).not.toBeNull();
|
|
2739
|
+
expect(screen.queryByTestId("row-r1-action-publish")).toBeNull();
|
|
2740
|
+
});
|
|
2741
|
+
});
|