@cosmicdrift/kumiko-renderer-web 0.243.3 → 0.244.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__/kumiko-screen.test.tsx +285 -1
- package/src/__tests__/primitives.test.tsx +18 -1
- package/src/__tests__/projection-detail.test.tsx +95 -2
- package/src/__tests__/projection-list-actions.test.tsx +74 -0
- package/src/primitives/__tests__/data-table-toolbar.test.tsx +35 -0
- package/src/primitives/index.tsx +4 -4
- package/src/primitives/layout.tsx +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.244.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.244.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.244.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.244.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.244.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
UserRolesProvider,
|
|
19
19
|
} from "@cosmicdrift/kumiko-renderer";
|
|
20
20
|
import userEvent from "@testing-library/user-event";
|
|
21
|
-
import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
21
|
+
import { createMockDispatcher, fireEvent, render, screen, waitFor, within } from "./test-utils";
|
|
22
22
|
|
|
23
23
|
const taskEntity = {
|
|
24
24
|
fields: {
|
|
@@ -1395,6 +1395,210 @@ describe("KumikoScreen", () => {
|
|
|
1395
1395
|
});
|
|
1396
1396
|
});
|
|
1397
1397
|
|
|
1398
|
+
// rowActions kind:"drawer" (fw#2710): row-level pendant to toolbarActions
|
|
1399
|
+
// kind:"drawer" (fw#2225) above — same DrawerHost/useDrawerAction
|
|
1400
|
+
// machinery, generalized to also serve a row-derived prefill.
|
|
1401
|
+
describe("entityList rowActions drawer-kind (fw#2710)", () => {
|
|
1402
|
+
const noteForm: ActionFormScreenDefinition = {
|
|
1403
|
+
id: "task-note-drawer",
|
|
1404
|
+
type: "actionForm",
|
|
1405
|
+
handler: "tasks:write:task:note",
|
|
1406
|
+
fields: {
|
|
1407
|
+
title: { type: "text" },
|
|
1408
|
+
note: { type: "text", required: true },
|
|
1409
|
+
secret: { type: "text", sensitive: true },
|
|
1410
|
+
},
|
|
1411
|
+
layout: { sections: [{ fields: ["title", "note", "secret"] }] },
|
|
1412
|
+
};
|
|
1413
|
+
// A single rowAction keeps the DataTable's inline-button rendering
|
|
1414
|
+
// (>2 actions collapse to a kebab dropdown, see ListColumnSpec.rowActions
|
|
1415
|
+
// doc) — each test below only exercises one action at a time.
|
|
1416
|
+
function screenWithRowAction(
|
|
1417
|
+
rowAction: NonNullable<EntityListScreenDefinition["rowActions"]>[number],
|
|
1418
|
+
): EntityListScreenDefinition {
|
|
1419
|
+
return {
|
|
1420
|
+
id: "task-list",
|
|
1421
|
+
type: "entityList",
|
|
1422
|
+
entity: "task",
|
|
1423
|
+
columns: ["title"],
|
|
1424
|
+
rowActions: [rowAction],
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function makeRowDrawerDispatcher(write?: Dispatcher["write"]): {
|
|
1429
|
+
dispatcher: Dispatcher;
|
|
1430
|
+
getQueryCallCount: () => number;
|
|
1431
|
+
} {
|
|
1432
|
+
let queryCallCount = 0;
|
|
1433
|
+
const dispatcher = makeDispatcher({
|
|
1434
|
+
query: (async () => {
|
|
1435
|
+
queryCallCount += 1;
|
|
1436
|
+
return {
|
|
1437
|
+
isSuccess: true,
|
|
1438
|
+
data: {
|
|
1439
|
+
rows: [{ id: "r1", title: "Alpha", count: 1, done: false, secret: "topsecret" }],
|
|
1440
|
+
nextCursor: null,
|
|
1441
|
+
},
|
|
1442
|
+
};
|
|
1443
|
+
}) as unknown as Dispatcher["query"],
|
|
1444
|
+
...(write !== undefined && { write }),
|
|
1445
|
+
});
|
|
1446
|
+
return { dispatcher, getQueryCallCount: () => queryCallCount };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
test("Click opens the Drawer with the row's value prefilled via `pick`", async () => {
|
|
1450
|
+
const rowDrawerSchema: FeatureSchema = {
|
|
1451
|
+
...schema,
|
|
1452
|
+
screens: [
|
|
1453
|
+
screenWithRowAction({
|
|
1454
|
+
kind: "drawer",
|
|
1455
|
+
id: "add-note",
|
|
1456
|
+
label: "actions.addNote",
|
|
1457
|
+
screen: "task-note-drawer",
|
|
1458
|
+
params: { pick: ["title"] },
|
|
1459
|
+
}),
|
|
1460
|
+
noteForm,
|
|
1461
|
+
],
|
|
1462
|
+
};
|
|
1463
|
+
const { dispatcher } = makeRowDrawerDispatcher();
|
|
1464
|
+
const user = userEvent.setup();
|
|
1465
|
+
render(
|
|
1466
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1467
|
+
<KumikoScreen schema={rowDrawerSchema} qn="tasks:screen:task-list" />
|
|
1468
|
+
</DispatcherProvider>,
|
|
1469
|
+
);
|
|
1470
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
1471
|
+
expect(screen.queryByTestId("field-title")).toBeNull();
|
|
1472
|
+
|
|
1473
|
+
await user.click(screen.getByTestId("row-r1-action-add-note"));
|
|
1474
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
1475
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input");
|
|
1476
|
+
if (titleInput === null) throw new Error("expected an <input> inside field-title");
|
|
1477
|
+
expect(titleInput.value).toBe("Alpha");
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
test("Click opens the Drawer with the row's value prefilled via `map`", async () => {
|
|
1481
|
+
const rowDrawerSchema: FeatureSchema = {
|
|
1482
|
+
...schema,
|
|
1483
|
+
screens: [
|
|
1484
|
+
screenWithRowAction({
|
|
1485
|
+
kind: "drawer",
|
|
1486
|
+
id: "add-note-mapped",
|
|
1487
|
+
label: "actions.addNoteMapped",
|
|
1488
|
+
screen: "task-note-drawer",
|
|
1489
|
+
params: { map: { note: "title" } },
|
|
1490
|
+
}),
|
|
1491
|
+
noteForm,
|
|
1492
|
+
],
|
|
1493
|
+
};
|
|
1494
|
+
const { dispatcher } = makeRowDrawerDispatcher();
|
|
1495
|
+
const user = userEvent.setup();
|
|
1496
|
+
render(
|
|
1497
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1498
|
+
<KumikoScreen schema={rowDrawerSchema} qn="tasks:screen:task-list" />
|
|
1499
|
+
</DispatcherProvider>,
|
|
1500
|
+
);
|
|
1501
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
1502
|
+
|
|
1503
|
+
await user.click(screen.getByTestId("row-r1-action-add-note-mapped"));
|
|
1504
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
1505
|
+
const noteInput = screen.getByTestId("field-note").querySelector("input");
|
|
1506
|
+
if (noteInput === null) throw new Error("expected an <input> inside field-note");
|
|
1507
|
+
expect(noteInput.value).toBe("Alpha");
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
// Locks in the mergeSearchParamsIntoInitial `sensitive` gate for the
|
|
1511
|
+
// drawer's direct-object overrides path — a `params` extractor naming a
|
|
1512
|
+
// sensitive field must not leak the row's value into the form.
|
|
1513
|
+
test("A sensitive field named by `params` is not prefilled", async () => {
|
|
1514
|
+
const rowDrawerSchema: FeatureSchema = {
|
|
1515
|
+
...schema,
|
|
1516
|
+
screens: [
|
|
1517
|
+
screenWithRowAction({
|
|
1518
|
+
kind: "drawer",
|
|
1519
|
+
id: "reveal",
|
|
1520
|
+
label: "actions.reveal",
|
|
1521
|
+
screen: "task-note-drawer",
|
|
1522
|
+
params: { pick: ["secret"] },
|
|
1523
|
+
}),
|
|
1524
|
+
noteForm,
|
|
1525
|
+
],
|
|
1526
|
+
};
|
|
1527
|
+
const { dispatcher } = makeRowDrawerDispatcher();
|
|
1528
|
+
const user = userEvent.setup();
|
|
1529
|
+
render(
|
|
1530
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1531
|
+
<KumikoScreen schema={rowDrawerSchema} qn="tasks:screen:task-list" />
|
|
1532
|
+
</DispatcherProvider>,
|
|
1533
|
+
);
|
|
1534
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
1535
|
+
|
|
1536
|
+
await user.click(screen.getByTestId("row-r1-action-reveal"));
|
|
1537
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
1538
|
+
const secretInput = screen.getByTestId("field-secret").querySelector("input");
|
|
1539
|
+
if (secretInput === null) throw new Error("expected an <input> inside field-secret");
|
|
1540
|
+
expect(secretInput.value).toBe("");
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
test("Successful submit dispatches the handler, closes the Drawer without navigating, and reloads the list", async () => {
|
|
1544
|
+
const rowDrawerSchema: FeatureSchema = {
|
|
1545
|
+
...schema,
|
|
1546
|
+
screens: [
|
|
1547
|
+
screenWithRowAction({
|
|
1548
|
+
kind: "drawer",
|
|
1549
|
+
id: "add-note",
|
|
1550
|
+
label: "actions.addNote",
|
|
1551
|
+
screen: "task-note-drawer",
|
|
1552
|
+
params: { pick: ["title"] },
|
|
1553
|
+
}),
|
|
1554
|
+
noteForm,
|
|
1555
|
+
],
|
|
1556
|
+
};
|
|
1557
|
+
const writeCalls: { type: string; payload: unknown }[] = [];
|
|
1558
|
+
const { dispatcher, getQueryCallCount } = makeRowDrawerDispatcher((async (
|
|
1559
|
+
type: string,
|
|
1560
|
+
payload: unknown,
|
|
1561
|
+
) => {
|
|
1562
|
+
writeCalls.push({ type, payload });
|
|
1563
|
+
return { isSuccess: true, data: {} };
|
|
1564
|
+
}) as unknown as Dispatcher["write"]);
|
|
1565
|
+
const navigateCalls: unknown[] = [];
|
|
1566
|
+
const memoryNav = {
|
|
1567
|
+
route: { screenId: "task-list" },
|
|
1568
|
+
navigate: (target: NavTarget) => {
|
|
1569
|
+
navigateCalls.push(target);
|
|
1570
|
+
},
|
|
1571
|
+
replace: () => undefined,
|
|
1572
|
+
hrefFor: () => "",
|
|
1573
|
+
searchParams: {},
|
|
1574
|
+
setSearchParams: () => undefined,
|
|
1575
|
+
};
|
|
1576
|
+
const user = userEvent.setup();
|
|
1577
|
+
render(
|
|
1578
|
+
<NavProvider value={memoryNav}>
|
|
1579
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1580
|
+
<KumikoScreen schema={rowDrawerSchema} qn="tasks:screen:task-list" />
|
|
1581
|
+
</DispatcherProvider>
|
|
1582
|
+
</NavProvider>,
|
|
1583
|
+
);
|
|
1584
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
1585
|
+
await user.click(screen.getByTestId("row-r1-action-add-note"));
|
|
1586
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
1587
|
+
const queryCallsBeforeSubmit = getQueryCallCount();
|
|
1588
|
+
|
|
1589
|
+
const noteInput = screen.getByTestId("field-note").querySelector("input");
|
|
1590
|
+
if (noteInput === null) throw new Error("expected an <input> inside field-note");
|
|
1591
|
+
fireEvent.change(noteInput, { target: { value: "hello" } });
|
|
1592
|
+
await clickSubmitOnceEnabled();
|
|
1593
|
+
|
|
1594
|
+
await waitFor(() => expect(writeCalls.length).toBe(1));
|
|
1595
|
+
expect(writeCalls[0]?.type).toBe("tasks:write:task:note");
|
|
1596
|
+
await waitFor(() => expect(screen.queryByTestId("render-edit-form")).toBeNull());
|
|
1597
|
+
await waitFor(() => expect(getQueryCallCount()).toBeGreaterThan(queryCallsBeforeSubmit));
|
|
1598
|
+
expect(navigateCalls).toEqual([]);
|
|
1599
|
+
});
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1398
1602
|
// Tier 2.7c: Screen-Level filter wird vom Schema in den Query-
|
|
1399
1603
|
// Payload propagiert. Drei Buckets ("scheduled" / "active" / "done")
|
|
1400
1604
|
// teilen sich denselben Query-Handler — der Filter unterscheidet
|
|
@@ -2682,6 +2886,86 @@ describe("KumikoScreen: entityEdit header actions", () => {
|
|
|
2682
2886
|
});
|
|
2683
2887
|
});
|
|
2684
2888
|
|
|
2889
|
+
// entityEdit.actions kind:"drawer" (fw#2710) — EntityEditUpdateForm owns its
|
|
2890
|
+
// own DrawerHost directly (5th RowAction/action call site, not covered by
|
|
2891
|
+
// the shared ToolbarAction plumbing's original scope). The outer update
|
|
2892
|
+
// form and the drawer's own ActionFormBody are both `RenderEdit` instances
|
|
2893
|
+
// mounted at once, so drawer-scoped queries use `within(...)` to avoid
|
|
2894
|
+
// colliding with the outer form's own render-edit-form/field-* testids.
|
|
2895
|
+
describe("KumikoScreen: entityEdit actions drawer-kind (fw#2710)", () => {
|
|
2896
|
+
const noteForm: ActionFormScreenDefinition = {
|
|
2897
|
+
id: "task-note-drawer",
|
|
2898
|
+
type: "actionForm",
|
|
2899
|
+
handler: "tasks:write:task:note",
|
|
2900
|
+
fields: { title: { type: "text" }, note: { type: "text", required: true } },
|
|
2901
|
+
layout: { sections: [{ fields: ["title", "note"] }] },
|
|
2902
|
+
};
|
|
2903
|
+
const editScreenWithDrawer: EntityEditScreenDefinition = {
|
|
2904
|
+
id: "task-edit-drawer",
|
|
2905
|
+
type: "entityEdit",
|
|
2906
|
+
entity: "task",
|
|
2907
|
+
layout: { sections: [{ title: "Basics", fields: ["title"] }] },
|
|
2908
|
+
actions: [
|
|
2909
|
+
{
|
|
2910
|
+
kind: "drawer",
|
|
2911
|
+
id: "add-note",
|
|
2912
|
+
label: "Add note",
|
|
2913
|
+
screen: "task-note-drawer",
|
|
2914
|
+
params: { pick: ["title"] },
|
|
2915
|
+
},
|
|
2916
|
+
],
|
|
2917
|
+
};
|
|
2918
|
+
const drawerSchema: FeatureSchema = {
|
|
2919
|
+
featureName: "tasks",
|
|
2920
|
+
entities: { task: taskEntity },
|
|
2921
|
+
screens: [editScreenWithDrawer, noteForm],
|
|
2922
|
+
};
|
|
2923
|
+
|
|
2924
|
+
test("Click opens the Drawer prefilled from the record; submit dispatches, closes without navigating, and reloads the record", async () => {
|
|
2925
|
+
let queryCallCount = 0;
|
|
2926
|
+
const writeCalls: { type: string; payload: unknown }[] = [];
|
|
2927
|
+
const dispatcher = makeDispatcher({
|
|
2928
|
+
query: (async () => {
|
|
2929
|
+
queryCallCount += 1;
|
|
2930
|
+
return {
|
|
2931
|
+
isSuccess: true,
|
|
2932
|
+
data: { id: "task-1", version: 1, title: "loaded", count: 0, done: false },
|
|
2933
|
+
};
|
|
2934
|
+
}) as unknown as Dispatcher["query"],
|
|
2935
|
+
write: (async (type: string, payload: unknown) => {
|
|
2936
|
+
writeCalls.push({ type, payload });
|
|
2937
|
+
return { isSuccess: true, data: {} };
|
|
2938
|
+
}) as unknown as Dispatcher["write"],
|
|
2939
|
+
});
|
|
2940
|
+
|
|
2941
|
+
render(
|
|
2942
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
2943
|
+
<KumikoScreen schema={drawerSchema} qn="tasks:screen:task-edit-drawer" entityId="task-1" />
|
|
2944
|
+
</DispatcherProvider>,
|
|
2945
|
+
);
|
|
2946
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2947
|
+
expect(screen.queryByTestId("field-note")).toBeNull();
|
|
2948
|
+
|
|
2949
|
+
fireEvent.click(screen.getByTestId("render-edit-action-add-note"));
|
|
2950
|
+
const drawer = () => within(screen.getByTestId("toolbar-drawer-add-note"));
|
|
2951
|
+
await waitFor(() => expect(drawer().getByTestId("field-note")).toBeTruthy());
|
|
2952
|
+
const titleInput = drawer().getByTestId("field-title").querySelector("input");
|
|
2953
|
+
if (titleInput === null) throw new Error("expected an <input> inside field-title");
|
|
2954
|
+
expect(titleInput.value).toBe("loaded");
|
|
2955
|
+
const queryCallsBeforeSubmit = queryCallCount;
|
|
2956
|
+
|
|
2957
|
+
const noteInput = drawer().getByTestId("field-note").querySelector("input");
|
|
2958
|
+
if (noteInput === null) throw new Error("expected an <input> inside field-note");
|
|
2959
|
+
fireEvent.change(noteInput, { target: { value: "hello" } });
|
|
2960
|
+
fireEvent.click(drawer().getByTestId("render-edit-submit"));
|
|
2961
|
+
|
|
2962
|
+
await waitFor(() => expect(writeCalls.length).toBe(1));
|
|
2963
|
+
expect(writeCalls[0]?.type).toBe("tasks:write:task:note");
|
|
2964
|
+
await waitFor(() => expect(screen.queryByTestId("field-note")).toBeNull());
|
|
2965
|
+
await waitFor(() => expect(queryCallCount).toBeGreaterThan(queryCallsBeforeSubmit));
|
|
2966
|
+
});
|
|
2967
|
+
});
|
|
2968
|
+
|
|
2685
2969
|
// --- entityList status cells + row actions (fw#2579, fw#2580) ---
|
|
2686
2970
|
// Both are leftovers from #2575: the tone heuristic and the icon-only
|
|
2687
2971
|
// collapse existed, but neither reached a list cell. These render the real
|
|
@@ -1743,10 +1743,27 @@ describe("PageSection", () => {
|
|
|
1743
1743
|
<span data-testid="child">x</span>
|
|
1744
1744
|
</PageSection>,
|
|
1745
1745
|
);
|
|
1746
|
-
expect(screen.getByTestId("p").className).toContain("
|
|
1746
|
+
expect(screen.getByTestId("p").className).toContain("px-6");
|
|
1747
1747
|
expect(screen.getByTestId("child")).toBeDefined();
|
|
1748
1748
|
});
|
|
1749
1749
|
|
|
1750
|
+
test("renders the same padding classes as FormScreenShell (fw#2640)", () => {
|
|
1751
|
+
render(
|
|
1752
|
+
<>
|
|
1753
|
+
<PageSection testId="page-pad">x</PageSection>
|
|
1754
|
+
<FormScreenShell testId="shell-pad">x</FormScreenShell>
|
|
1755
|
+
</>,
|
|
1756
|
+
);
|
|
1757
|
+
const paddingOf = (testId: string): string[] =>
|
|
1758
|
+
screen
|
|
1759
|
+
.getByTestId(testId)
|
|
1760
|
+
.className.split(" ")
|
|
1761
|
+
.filter((c) => /^p[xytblr]?-/.test(c))
|
|
1762
|
+
.sort();
|
|
1763
|
+
expect(paddingOf("page-pad")).toEqual(["pb-12", "pt-6", "px-6"]);
|
|
1764
|
+
expect(paddingOf("page-pad")).toEqual(paddingOf("shell-pad"));
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1750
1767
|
test("maxWidth=4xl sets the same width class as FormScreenShell maxWidth=4xl (fw#2640)", () => {
|
|
1751
1768
|
render(
|
|
1752
1769
|
<>
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
// - fehlende entityId → Error-Banner statt Crash
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
ActionFormScreenDefinition,
|
|
11
|
+
ProjectionDetailScreenDefinition,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
10
13
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
11
14
|
import type {
|
|
12
15
|
ExtensionSectionProps,
|
|
@@ -24,7 +27,15 @@ import {
|
|
|
24
27
|
} from "@cosmicdrift/kumiko-renderer";
|
|
25
28
|
import type { ReactNode } from "react";
|
|
26
29
|
import { BareFormProvider } from "../primitives";
|
|
27
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
act,
|
|
32
|
+
createMockDispatcher,
|
|
33
|
+
fireEvent,
|
|
34
|
+
render,
|
|
35
|
+
screen,
|
|
36
|
+
waitFor,
|
|
37
|
+
within,
|
|
38
|
+
} from "./test-utils";
|
|
28
39
|
|
|
29
40
|
const detailScreen: ProjectionDetailScreenDefinition = {
|
|
30
41
|
id: "session-detail",
|
|
@@ -623,6 +634,88 @@ describe("KumikoScreen / projectionDetail extension section (solon#264)", () =>
|
|
|
623
634
|
});
|
|
624
635
|
});
|
|
625
636
|
|
|
637
|
+
// projectionDetail.actions kind:"drawer" (fw#2710) — same shared DrawerHost
|
|
638
|
+
// as toolbarActions/rowActions, wired into ProjectionDetailBody's own
|
|
639
|
+
// headerActions builder (ProjectionDetailBody owns the Drawer directly,
|
|
640
|
+
// unlike relatedList sections which only forward through onOpenDrawer).
|
|
641
|
+
describe("KumikoScreen / projectionDetail actions drawer-kind (fw#2710)", () => {
|
|
642
|
+
const noteForm: ActionFormScreenDefinition = {
|
|
643
|
+
id: "session-note-drawer",
|
|
644
|
+
type: "actionForm",
|
|
645
|
+
handler: "sessions:write:session:note",
|
|
646
|
+
fields: { userId: { type: "text" }, note: { type: "text", required: true } },
|
|
647
|
+
layout: { sections: [{ fields: ["userId", "note"] }] },
|
|
648
|
+
};
|
|
649
|
+
const screenWithDrawerAction: ProjectionDetailScreenDefinition = {
|
|
650
|
+
...detailScreen,
|
|
651
|
+
actions: [
|
|
652
|
+
{
|
|
653
|
+
kind: "drawer",
|
|
654
|
+
id: "add-note",
|
|
655
|
+
label: "actions.addNote",
|
|
656
|
+
screen: "session-note-drawer",
|
|
657
|
+
params: { pick: ["userId"] },
|
|
658
|
+
},
|
|
659
|
+
],
|
|
660
|
+
};
|
|
661
|
+
const drawerSchema: FeatureSchema = {
|
|
662
|
+
featureName: "sessions",
|
|
663
|
+
entities: {},
|
|
664
|
+
screens: [screenWithDrawerAction, noteForm],
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
test("Click opens the Drawer prefilled from the record; submit dispatches, closes without navigating, and reloads the detail", async () => {
|
|
668
|
+
let queryCallCount = 0;
|
|
669
|
+
const write = (async (_type: string, _payload: unknown) => ({
|
|
670
|
+
isSuccess: true,
|
|
671
|
+
data: {},
|
|
672
|
+
})) as unknown as Dispatcher["write"];
|
|
673
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
674
|
+
query: (async () => {
|
|
675
|
+
queryCallCount += 1;
|
|
676
|
+
return {
|
|
677
|
+
isSuccess: true,
|
|
678
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
679
|
+
};
|
|
680
|
+
}) as unknown as Dispatcher["query"],
|
|
681
|
+
write,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
render(
|
|
685
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
686
|
+
<KumikoScreen schema={drawerSchema} qn="sessions:screen:session-detail" entityId="sess-1" />
|
|
687
|
+
</DispatcherProvider>,
|
|
688
|
+
);
|
|
689
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
690
|
+
expect(screen.queryByTestId("field-note")).toBeNull();
|
|
691
|
+
|
|
692
|
+
fireEvent.click(screen.getByTestId("render-edit-action-add-note"));
|
|
693
|
+
// The outer detail RenderEdit and the drawer's own ActionFormBody
|
|
694
|
+
// RenderEdit are both mounted at once — scope queries to the drawer
|
|
695
|
+
// container so they don't collide with the outer screen's own
|
|
696
|
+
// render-edit-form/field-userId testids.
|
|
697
|
+
const drawer = () => within(screen.getByTestId("toolbar-drawer-add-note"));
|
|
698
|
+
await waitFor(() => expect(drawer().getByTestId("field-note")).toBeTruthy());
|
|
699
|
+
const userIdInput = drawer().getByTestId("field-userId").querySelector("input");
|
|
700
|
+
if (userIdInput === null) throw new Error("expected an <input> inside field-userId");
|
|
701
|
+
expect(userIdInput.value).toBe("user-42");
|
|
702
|
+
const queryCallsBeforeSubmit = queryCallCount;
|
|
703
|
+
|
|
704
|
+
const noteInput = drawer().getByTestId("field-note").querySelector("input");
|
|
705
|
+
if (noteInput === null) throw new Error("expected an <input> inside field-note");
|
|
706
|
+
await act(async () => {
|
|
707
|
+
fireEvent.change(noteInput, { target: { value: "hello" } });
|
|
708
|
+
});
|
|
709
|
+
await act(async () => {
|
|
710
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
await waitFor(() => expect(screen.queryByTestId("field-note")).toBeNull());
|
|
714
|
+
await waitFor(() => expect(queryCallCount).toBeGreaterThan(queryCallsBeforeSubmit));
|
|
715
|
+
expect(screen.getByTestId("field-userId").textContent).toContain("user-42");
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
626
719
|
// fw#2312 mounts extension sections (ExtensionSectionMount, render-edit.tsx)
|
|
627
720
|
// INSIDE RenderEdit's own host <form testId="render-edit-form"> (render-edit.tsx:1059).
|
|
628
721
|
// A section that renders its own <form> — the pattern ChangeEmailSection/
|
|
@@ -201,3 +201,77 @@ describe("projectionList toolbarActions drawer-kind (fw#2225)", () => {
|
|
|
201
201
|
await waitFor(() => expect(queryCallCount).toBeGreaterThan(queryCallsBeforeSubmit));
|
|
202
202
|
});
|
|
203
203
|
});
|
|
204
|
+
|
|
205
|
+
// rowActions kind:"drawer" (fw#2710) — buildProjectionRowActions is shared
|
|
206
|
+
// between entityList and projectionList; this pins the projectionList call
|
|
207
|
+
// site (openDrawer wired through ProjectionListBody).
|
|
208
|
+
describe("projectionList rowActions drawer-kind (fw#2710)", () => {
|
|
209
|
+
const noteForm: ActionFormScreenDefinition = {
|
|
210
|
+
id: "maintenance-note",
|
|
211
|
+
type: "actionForm",
|
|
212
|
+
handler: "status:write:maintenance:note",
|
|
213
|
+
fields: { name: { type: "text" }, note: { type: "text", required: true } },
|
|
214
|
+
layout: { sections: [{ fields: ["name", "note"] }] },
|
|
215
|
+
};
|
|
216
|
+
const screenWithRowDrawer: ProjectionListScreenDefinition = {
|
|
217
|
+
...projectionScreen,
|
|
218
|
+
rowActions: [
|
|
219
|
+
{
|
|
220
|
+
kind: "drawer",
|
|
221
|
+
id: "add-note",
|
|
222
|
+
label: "status:action:add-note",
|
|
223
|
+
screen: "maintenance-note",
|
|
224
|
+
params: { pick: ["name"] },
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
const drawerSchema: FeatureSchema = {
|
|
229
|
+
featureName: "status",
|
|
230
|
+
entities: {},
|
|
231
|
+
screens: [screenWithRowDrawer, noteForm],
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
test("Click opens the Drawer prefilled from the row; submit dispatches, closes without navigating, and reloads the list", async () => {
|
|
235
|
+
let queryCallCount = 0;
|
|
236
|
+
const write = mock(async (_type: string, _payload: unknown) => ({
|
|
237
|
+
isSuccess: true,
|
|
238
|
+
data: {},
|
|
239
|
+
}));
|
|
240
|
+
const dispatcher: Dispatcher = {
|
|
241
|
+
...createMockDispatcher({
|
|
242
|
+
query: (async () => {
|
|
243
|
+
queryCallCount += 1;
|
|
244
|
+
return {
|
|
245
|
+
isSuccess: true,
|
|
246
|
+
data: { rows: [{ id: "m1", name: "DB-Upgrade" }], nextCursor: null },
|
|
247
|
+
};
|
|
248
|
+
}) as unknown as Dispatcher["query"],
|
|
249
|
+
}),
|
|
250
|
+
write: write as unknown as Dispatcher["write"],
|
|
251
|
+
};
|
|
252
|
+
render(
|
|
253
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
254
|
+
<KumikoScreen schema={drawerSchema} qn="status:screen:maintenance-list" />
|
|
255
|
+
</DispatcherProvider>,
|
|
256
|
+
);
|
|
257
|
+
await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
|
|
258
|
+
expect(screen.queryByTestId("field-name")).toBeNull();
|
|
259
|
+
|
|
260
|
+
fireEvent.click(screen.getByTestId("row-m1-action-add-note"));
|
|
261
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
262
|
+
const nameInput = screen.getByTestId("field-name").querySelector("input");
|
|
263
|
+
if (nameInput === null) throw new Error("expected an <input> inside field-name");
|
|
264
|
+
expect(nameInput.value).toBe("DB-Upgrade");
|
|
265
|
+
const queryCallsBeforeSubmit = queryCallCount;
|
|
266
|
+
|
|
267
|
+
const noteInput = screen.getByTestId("field-note").querySelector("input");
|
|
268
|
+
if (noteInput === null) throw new Error("expected an <input> inside field-note");
|
|
269
|
+
fireEvent.change(noteInput, { target: { value: "hello" } });
|
|
270
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
271
|
+
|
|
272
|
+
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
|
273
|
+
expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:note");
|
|
274
|
+
await waitFor(() => expect(screen.queryByTestId("render-edit-form")).toBeNull());
|
|
275
|
+
await waitFor(() => expect(queryCallCount).toBeGreaterThan(queryCallsBeforeSubmit));
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { defaultPrimitives } from "../index";
|
|
4
|
+
|
|
5
|
+
const { DataTable, Button } = defaultPrimitives;
|
|
6
|
+
|
|
7
|
+
// coa-mapping-list / statement-upload-list on a 390px viewport: toolbarEnd's
|
|
8
|
+
// rightmost button got cut off because neither the toolbar container nor its
|
|
9
|
+
// toolbarEnd wrapper allowed wrapping. happy-dom doesn't compute real layout
|
|
10
|
+
// (no measured widths here), so this pins the classes that make wrapping
|
|
11
|
+
// possible instead: both containers carry flex-wrap, and toolbarEnd's
|
|
12
|
+
// buttons sit inside that wrapping container rather than one that would
|
|
13
|
+
// force them onto a single non-wrapping row.
|
|
14
|
+
describe("DataTable toolbar wraps instead of overflowing on narrow viewports", () => {
|
|
15
|
+
test("toolbar container and toolbarEnd wrapper both allow wrapping", () => {
|
|
16
|
+
render(
|
|
17
|
+
<DataTable
|
|
18
|
+
columns={[]}
|
|
19
|
+
rows={[]}
|
|
20
|
+
testId="tbl"
|
|
21
|
+
toolbarStart={<div>search</div>}
|
|
22
|
+
toolbarEnd={<Button testId="tbl-action">Action</Button>}
|
|
23
|
+
/>,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const toolbar = screen.getByTestId("tbl-toolbar");
|
|
27
|
+
expect(toolbar.className).toContain("flex-wrap");
|
|
28
|
+
|
|
29
|
+
const actionButton = screen.getByTestId("tbl-action");
|
|
30
|
+
const toolbarEndWrapper = actionButton.parentElement;
|
|
31
|
+
expect(toolbarEndWrapper).not.toBeNull();
|
|
32
|
+
expect(toolbarEndWrapper?.className).toContain("flex-wrap");
|
|
33
|
+
expect(toolbarEndWrapper?.parentElement).toBe(toolbar);
|
|
34
|
+
});
|
|
35
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -110,7 +110,7 @@ import {
|
|
|
110
110
|
import { EmbeddedListInput } from "./embedded-list-input";
|
|
111
111
|
import { FileUploadInput } from "./file-upload";
|
|
112
112
|
import { DefaultJsonView } from "./json-view";
|
|
113
|
-
import { screenWidthClassName } from "./layout";
|
|
113
|
+
import { screenPaddingClassName, screenWidthClassName } from "./layout";
|
|
114
114
|
import { DefaultLightbox } from "./lightbox";
|
|
115
115
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
116
116
|
import { DefaultMetric } from "./metric";
|
|
@@ -1238,12 +1238,12 @@ function DefaultDataTable({
|
|
|
1238
1238
|
{hasToolbar && (
|
|
1239
1239
|
<div
|
|
1240
1240
|
data-testid={testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar"}
|
|
1241
|
-
className="flex items-center gap-3"
|
|
1241
|
+
className="flex flex-wrap items-center gap-3"
|
|
1242
1242
|
>
|
|
1243
1243
|
{toolbarStart !== undefined && <div className="flex-1 max-w-sm">{toolbarStart}</div>}
|
|
1244
1244
|
{facetCluster}
|
|
1245
1245
|
{toolbarEnd !== undefined && (
|
|
1246
|
-
<div className="flex items-center gap-2 ml-auto">{toolbarEnd}</div>
|
|
1246
|
+
<div className="flex flex-wrap items-center gap-2 ml-auto">{toolbarEnd}</div>
|
|
1247
1247
|
)}
|
|
1248
1248
|
</div>
|
|
1249
1249
|
)}
|
|
@@ -2261,7 +2261,7 @@ export function FormScreenShell({
|
|
|
2261
2261
|
return (
|
|
2262
2262
|
<div
|
|
2263
2263
|
data-testid={testId}
|
|
2264
|
-
className={cn("
|
|
2264
|
+
className={cn(screenPaddingClassName, "w-full", screenWidthClassName[width], className)}
|
|
2265
2265
|
>
|
|
2266
2266
|
{children}
|
|
2267
2267
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Layout primitives for custom screens: one place for vertical spacing and
|
|
2
|
-
// screen padding, so consumers don't hand-roll `flex flex-col gap-*` /
|
|
2
|
+
// screen padding, so consumers don't hand-roll `flex flex-col gap-*` / screen insets.
|
|
3
3
|
// Deliberately thin — not a generic box-with-20-props system.
|
|
4
4
|
|
|
5
5
|
import type { FormWidth } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -17,6 +17,12 @@ export const screenWidthClassName: Record<ScreenWidth, string> = {
|
|
|
17
17
|
full: "max-w-full",
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
// Shared screen padding for both screen containers (PageSection and
|
|
21
|
+
// FormScreenShell). The wider bottom inset keeps the last field or table row
|
|
22
|
+
// off the viewport edge; applying it everywhere makes footer spacing
|
|
23
|
+
// independent of the screen type (fw#2640).
|
|
24
|
+
export const screenPaddingClassName = "px-6 pt-6 pb-12";
|
|
25
|
+
|
|
20
26
|
const STACK_GAP = { sm: "gap-2", md: "gap-4", lg: "gap-6" } as const;
|
|
21
27
|
|
|
22
28
|
type StackGap = keyof typeof STACK_GAP;
|
|
@@ -50,7 +56,10 @@ export function PageSection({
|
|
|
50
56
|
maxWidth = "full",
|
|
51
57
|
}: PageSectionProps): ReactNode {
|
|
52
58
|
return (
|
|
53
|
-
<div
|
|
59
|
+
<div
|
|
60
|
+
data-testid={testId}
|
|
61
|
+
className={cn(screenPaddingClassName, screenWidthClassName[maxWidth], className)}
|
|
62
|
+
>
|
|
54
63
|
{children}
|
|
55
64
|
</div>
|
|
56
65
|
);
|