@cosmicdrift/kumiko-renderer-web 0.243.2 → 0.243.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.243.2",
3
+ "version": "0.243.4",
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.243.2",
20
- "@cosmicdrift/kumiko-headless": "0.243.2",
21
- "@cosmicdrift/kumiko-renderer": "0.243.2",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.243.4",
20
+ "@cosmicdrift/kumiko-headless": "0.243.4",
21
+ "@cosmicdrift/kumiko-renderer": "0.243.4",
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.243.2"
67
+ "@cosmicdrift/kumiko-locale-de": "0.243.4"
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
@@ -6,16 +6,36 @@
6
6
  // - fehlende entityId → Error-Banner statt Crash
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
9
+ import type {
10
+ ActionFormScreenDefinition,
11
+ ProjectionDetailScreenDefinition,
12
+ } from "@cosmicdrift/kumiko-framework/ui-types";
10
13
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
11
- import type { FeatureSchema, NavApi, NavTarget } from "@cosmicdrift/kumiko-renderer";
14
+ import type {
15
+ ExtensionSectionProps,
16
+ FeatureSchema,
17
+ NavApi,
18
+ NavTarget,
19
+ } from "@cosmicdrift/kumiko-renderer";
12
20
  import {
13
21
  DispatcherProvider,
14
22
  ExtensionSectionsProvider,
15
23
  KumikoScreen,
16
24
  NavProvider,
25
+ useDispatcher,
26
+ usePrimitives,
17
27
  } from "@cosmicdrift/kumiko-renderer";
18
- import { act, createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
28
+ import type { ReactNode } from "react";
29
+ import { BareFormProvider } from "../primitives";
30
+ import {
31
+ act,
32
+ createMockDispatcher,
33
+ fireEvent,
34
+ render,
35
+ screen,
36
+ waitFor,
37
+ within,
38
+ } from "./test-utils";
19
39
 
20
40
  const detailScreen: ProjectionDetailScreenDefinition = {
21
41
  id: "session-detail",
@@ -613,3 +633,293 @@ describe("KumikoScreen / projectionDetail extension section (solon#264)", () =>
613
633
  expect(screen.queryByTestId("field-userId")).toBeNull();
614
634
  });
615
635
  });
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
+
719
+ // fw#2312 mounts extension sections (ExtensionSectionMount, render-edit.tsx)
720
+ // INSIDE RenderEdit's own host <form testId="render-edit-form"> (render-edit.tsx:1059).
721
+ // A section that renders its own <form> — the pattern ChangeEmailSection/
722
+ // ChangePasswordSection used before fw#2703 via BareFormProvider — used to
723
+ // produce two nested <form> elements, invalid DOM that let real browsers
724
+ // silently degrade the inner submit into a native GET navigation of the
725
+ // OUTER form (offlot-app e2e, currentPassword leaked into the URL). fw#2705
726
+ // fixes the cause in DefaultForm (primitives/index.tsx): it degrades to a
727
+ // <div> whenever InsideFormContext says it is already nested in a form, and
728
+ // intercepts a click on its own submit button (onClickCapture +
729
+ // preventDefault) instead of relying on the browser to associate the button
730
+ // with a form. The tests below assert that structurally (exactly one
731
+ // <form>) and prove the degraded section's own submit still reaches its
732
+ // dispatcher, without depending on the button-to-form association the
733
+ // removed nested <form> used to rely on.
734
+ describe("KumikoScreen / projectionDetail extension section with its own <form> (fw#2312/fw#2705 nested-form regression)", () => {
735
+ function FormExtensionSection({ entityId }: ExtensionSectionProps): ReactNode {
736
+ const dispatcher = useDispatcher();
737
+ const { Form, Button } = usePrimitives();
738
+ const onSubmit = (): void => {
739
+ void dispatcher.write("sessions:write:user-session:update", {
740
+ id: entityId,
741
+ note: "updated",
742
+ });
743
+ };
744
+ return (
745
+ <BareFormProvider>
746
+ <Form
747
+ testId="nested-section-form"
748
+ onSubmit={onSubmit}
749
+ actions={
750
+ <Button type="submit" testId="nested-section-submit">
751
+ Save note
752
+ </Button>
753
+ }
754
+ >
755
+ <div data-testid="nested-section-marker" />
756
+ </Form>
757
+ </BareFormProvider>
758
+ );
759
+ }
760
+
761
+ test("mounted through KumikoScreen -> ProjectionDetailBody -> RenderEdit, the section's own form degrades to a <div> so only one <form> lands in the DOM", async () => {
762
+ const extensionScreen: ProjectionDetailScreenDefinition = {
763
+ ...detailScreen,
764
+ layout: {
765
+ sections: [
766
+ ...detailScreen.layout.sections,
767
+ {
768
+ kind: "extension",
769
+ title: "Note",
770
+ component: { react: { __component: "FormExtensionSection" } },
771
+ entityName: "user-session",
772
+ },
773
+ ],
774
+ },
775
+ };
776
+ const extensionSchema: FeatureSchema = {
777
+ featureName: "sessions",
778
+ entities: {},
779
+ screens: [extensionScreen],
780
+ };
781
+ const dispatcher: Dispatcher = createMockDispatcher({
782
+ query: (async () => ({
783
+ isSuccess: true,
784
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
785
+ })) as unknown as Dispatcher["query"],
786
+ });
787
+
788
+ const { container } = render(
789
+ <DispatcherProvider dispatcher={dispatcher}>
790
+ <ExtensionSectionsProvider value={{ FormExtensionSection }}>
791
+ <KumikoScreen
792
+ schema={extensionSchema}
793
+ qn="sessions:screen:session-detail"
794
+ entityId="sess-1"
795
+ />
796
+ </ExtensionSectionsProvider>
797
+ </DispatcherProvider>,
798
+ );
799
+
800
+ await waitFor(() => screen.getByTestId("nested-section-form"));
801
+ // Structural proof of the fix: RenderEdit renders one
802
+ // <form testId="render-edit-form"> around the whole screen. The
803
+ // nested section's own <Form> (still wrapped in BareFormProvider) must
804
+ // degrade to a <div> (FormRoot, primitives/index.tsx) instead of
805
+ // adding a second <form>.
806
+ expect(container.querySelectorAll("form")).toHaveLength(1);
807
+ });
808
+
809
+ test("clicking the nested section's own submit button dispatches its write, even though FormRoot degraded its <Form> to a <div>", async () => {
810
+ const writes: Array<{ type: string; payload: unknown }> = [];
811
+ const extensionScreen: ProjectionDetailScreenDefinition = {
812
+ ...detailScreen,
813
+ layout: {
814
+ sections: [
815
+ ...detailScreen.layout.sections,
816
+ {
817
+ kind: "extension",
818
+ title: "Note",
819
+ component: { react: { __component: "FormExtensionSection" } },
820
+ entityName: "user-session",
821
+ },
822
+ ],
823
+ },
824
+ };
825
+ const extensionSchema: FeatureSchema = {
826
+ featureName: "sessions",
827
+ entities: {},
828
+ screens: [extensionScreen],
829
+ };
830
+ const dispatcher: Dispatcher = createMockDispatcher({
831
+ query: (async () => ({
832
+ isSuccess: true,
833
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
834
+ })) as unknown as Dispatcher["query"],
835
+ write: (async (type: string, payload: unknown) => {
836
+ writes.push({ type, payload });
837
+ return { isSuccess: true, data: {} };
838
+ }) as unknown as Dispatcher["write"],
839
+ });
840
+
841
+ render(
842
+ <DispatcherProvider dispatcher={dispatcher}>
843
+ <ExtensionSectionsProvider value={{ FormExtensionSection }}>
844
+ <KumikoScreen
845
+ schema={extensionSchema}
846
+ qn="sessions:screen:session-detail"
847
+ entityId="sess-1"
848
+ />
849
+ </ExtensionSectionsProvider>
850
+ </DispatcherProvider>,
851
+ );
852
+
853
+ await waitFor(() => screen.getByTestId("nested-section-submit"));
854
+ fireEvent.click(screen.getByTestId("nested-section-submit"));
855
+
856
+ await waitFor(() => {
857
+ if (writes.length === 0) throw new Error("no write dispatched yet");
858
+ });
859
+ expect(writes[0]).toEqual({
860
+ type: "sessions:write:user-session:update",
861
+ payload: { id: "sess-1", note: "updated" },
862
+ });
863
+ });
864
+
865
+ // Complements the dispatch test above: with the section's own <Form>
866
+ // degraded to a <div>, its submit button sits inside the SAME real <form>
867
+ // as render-edit-form. Without FormRoot's onClickCapture + preventDefault,
868
+ // activating that type="submit" button would fire the OUTER form's native
869
+ // submit instead of routing to the section's onSubmit — the exact fw#2705
870
+ // hazard the nested-<form> version produced, just surfacing here as a
871
+ // wrong dispatch target rather than a URL GET (the dispatch test above
872
+ // cannot tell the two apart: RenderEdit's own handleSubmit is a no-op for
873
+ // this readOnly projectionDetail screen either way).
874
+ test("clicking the nested section's own submit button does not also fire a native submit on the outer host <form>", async () => {
875
+ const extensionScreen: ProjectionDetailScreenDefinition = {
876
+ ...detailScreen,
877
+ layout: {
878
+ sections: [
879
+ ...detailScreen.layout.sections,
880
+ {
881
+ kind: "extension",
882
+ title: "Note",
883
+ component: { react: { __component: "FormExtensionSection" } },
884
+ entityName: "user-session",
885
+ },
886
+ ],
887
+ },
888
+ };
889
+ const extensionSchema: FeatureSchema = {
890
+ featureName: "sessions",
891
+ entities: {},
892
+ screens: [extensionScreen],
893
+ };
894
+ const dispatcher: Dispatcher = createMockDispatcher({
895
+ query: (async () => ({
896
+ isSuccess: true,
897
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
898
+ })) as unknown as Dispatcher["query"],
899
+ });
900
+
901
+ const { container } = render(
902
+ <DispatcherProvider dispatcher={dispatcher}>
903
+ <ExtensionSectionsProvider value={{ FormExtensionSection }}>
904
+ <KumikoScreen
905
+ schema={extensionSchema}
906
+ qn="sessions:screen:session-detail"
907
+ entityId="sess-1"
908
+ />
909
+ </ExtensionSectionsProvider>
910
+ </DispatcherProvider>,
911
+ );
912
+
913
+ await waitFor(() => screen.getByTestId("nested-section-submit"));
914
+ const outerForm = container.querySelector('[data-testid="render-edit-form"]');
915
+ expect(outerForm).not.toBeNull();
916
+ let outerSubmitFired = false;
917
+ outerForm?.addEventListener("submit", () => {
918
+ outerSubmitFired = true;
919
+ });
920
+
921
+ fireEvent.click(screen.getByTestId("nested-section-submit"));
922
+
923
+ expect(outerSubmitFired).toBe(false);
924
+ });
925
+ });
@@ -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
+ });
@@ -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
  )}
@@ -2063,6 +2063,59 @@ export function ScreenWidthProvider({
2063
2063
  return <ScreenWidthContext.Provider value={width}>{children}</ScreenWidthContext.Provider>;
2064
2064
  }
2065
2065
 
2066
+ // Extension sections that render their own <form> via BareFormProvider
2067
+ // (legacy custom-form pattern, e.g. ChangeEmailSection before fw#2703) land
2068
+ // inside RenderEdit's host <form> (ExtensionSectionMount, render-edit.tsx) —
2069
+ // nested <form> elements are invalid DOM, and real browsers can silently
2070
+ // fall back to a native GET submit of the OUTER form, leaking field values
2071
+ // into the URL (fw#2312, fw#2705). Degrading to a <div> when already inside
2072
+ // a form avoids the nesting; the captured click still routes to THIS form's
2073
+ // onSubmit instead of activating the real ancestor <form>.
2074
+ function FormRoot({
2075
+ onSubmit,
2076
+ testId,
2077
+ className,
2078
+ children,
2079
+ }: {
2080
+ readonly onSubmit: FormProps["onSubmit"];
2081
+ readonly testId?: string;
2082
+ readonly className?: string;
2083
+ readonly children: ReactNode;
2084
+ }): ReactNode {
2085
+ if (useContext(InsideFormContext)) {
2086
+ return (
2087
+ <div
2088
+ onClickCapture={(e) => {
2089
+ // @cast-boundary dom-event-target: closest() needs an Element, and
2090
+ // click targets are always one in the browser/happy-dom.
2091
+ const submitButton = (e.target as HTMLElement).closest(
2092
+ "button[type=submit], button:not([type])",
2093
+ );
2094
+ if (submitButton === null) return;
2095
+ e.preventDefault();
2096
+ onSubmit();
2097
+ }}
2098
+ data-testid={testId}
2099
+ className={className}
2100
+ >
2101
+ {children}
2102
+ </div>
2103
+ );
2104
+ }
2105
+ return (
2106
+ <form
2107
+ onSubmit={(e) => {
2108
+ e.preventDefault();
2109
+ onSubmit(e);
2110
+ }}
2111
+ data-testid={testId}
2112
+ className={className}
2113
+ >
2114
+ {children}
2115
+ </form>
2116
+ );
2117
+ }
2118
+
2066
2119
  function DefaultForm({
2067
2120
  onSubmit,
2068
2121
  children,
@@ -2079,12 +2132,9 @@ function DefaultForm({
2079
2132
  // der Container trägt Card/Titel selbst, sonst Card-in-Card.
2080
2133
  if (useContext(BareFormContext)) {
2081
2134
  return (
2082
- <form
2083
- onSubmit={(e) => {
2084
- e.preventDefault();
2085
- onSubmit(e);
2086
- }}
2087
- data-testid={testId}
2135
+ <FormRoot
2136
+ onSubmit={onSubmit}
2137
+ testId={testId}
2088
2138
  className={cn(
2089
2139
  "flex flex-col gap-4",
2090
2140
  // Bare forms stack sections without a card; without a divider a
@@ -2099,7 +2149,7 @@ function DefaultForm({
2099
2149
  {actions}
2100
2150
  </div>
2101
2151
  )}
2102
- </form>
2152
+ </FormRoot>
2103
2153
  );
2104
2154
  }
2105
2155
 
@@ -2107,14 +2157,7 @@ function DefaultForm({
2107
2157
  // between each other, muted action footer. Shell width defaults to full
2108
2158
  // (same chrome as lists); pass width to narrow (auth-adjacent / dense).
2109
2159
  return (
2110
- <form
2111
- onSubmit={(e) => {
2112
- e.preventDefault();
2113
- onSubmit(e);
2114
- }}
2115
- data-testid={testId}
2116
- className="flex flex-col w-full"
2117
- >
2160
+ <FormRoot onSubmit={onSubmit} testId={testId} className="flex flex-col w-full">
2118
2161
  <FormScreenShell {...(width !== undefined && { maxWidth: width })}>
2119
2162
  {headerRegion !== undefined && (
2120
2163
  <div className="flex flex-col gap-6 mb-8">{headerRegion}</div>
@@ -2191,7 +2234,7 @@ function DefaultForm({
2191
2234
  )}
2192
2235
  </div>
2193
2236
  </FormScreenShell>
2194
- </form>
2237
+ </FormRoot>
2195
2238
  );
2196
2239
  }
2197
2240