@cosmicdrift/kumiko-renderer-web 0.219.0 → 0.220.1
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.
|
|
3
|
+
"version": "0.220.1",
|
|
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.220.1",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.220.1",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.220.1",
|
|
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.220.1"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -24,8 +24,8 @@ describe("DefaultForm Action-Footer", () => {
|
|
|
24
24
|
const actions = screen.getByTestId("f-actions");
|
|
25
25
|
expect(actions.className).toContain("justify-end");
|
|
26
26
|
expect(actions.className).toContain("border-t");
|
|
27
|
-
// Footer
|
|
28
|
-
expect(actions.closest(".max-w-
|
|
27
|
+
// Footer sits in the form shell (buttons align with the field edge).
|
|
28
|
+
expect(actions.closest(".max-w-full")).toBeTruthy();
|
|
29
29
|
// Titel ist ein eigenes Heading oben, NICHT mehr in der Action-Bar.
|
|
30
30
|
expect(actions.textContent).not.toContain("Titel");
|
|
31
31
|
expect(screen.getByTestId("f-title").textContent).toBe("Titel");
|
|
@@ -1570,8 +1570,8 @@ describe("KumikoScreen", () => {
|
|
|
1570
1570
|
expect(writeCalls[0]?.payload).toEqual({ title: "New Task", priority: 1 });
|
|
1571
1571
|
});
|
|
1572
1572
|
|
|
1573
|
-
test("actionForm mit redirect: nach success → nav.navigate({screenId: redirect})", async () => {
|
|
1574
|
-
const navigateCalls:
|
|
1573
|
+
test("actionForm mit redirect: nach success → nav.navigate({screenId: redirect, entityId})", async () => {
|
|
1574
|
+
const navigateCalls: NavTarget[] = [];
|
|
1575
1575
|
const dispatcher = makeDispatcher({
|
|
1576
1576
|
write: (async () => ({
|
|
1577
1577
|
isSuccess: true,
|
|
@@ -1613,11 +1613,13 @@ describe("KumikoScreen", () => {
|
|
|
1613
1613
|
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1614
1614
|
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1615
1615
|
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1616
|
-
|
|
1616
|
+
// #2416: the handler returns a created id (`{ id: "x" }`) — it must land
|
|
1617
|
+
// on the navigate as entityId, same as entityEdit-create.
|
|
1618
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-list", entityId: "x" });
|
|
1617
1619
|
});
|
|
1618
1620
|
|
|
1619
1621
|
test("actionForm mit Cross-Feature-QN als redirect: navigiert per Short-Id (#1946)", async () => {
|
|
1620
|
-
const navigateCalls:
|
|
1622
|
+
const navigateCalls: NavTarget[] = [];
|
|
1621
1623
|
const dispatcher = makeDispatcher({
|
|
1622
1624
|
write: (async () => ({
|
|
1623
1625
|
isSuccess: true,
|
|
@@ -1659,7 +1661,59 @@ describe("KumikoScreen", () => {
|
|
|
1659
1661
|
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1660
1662
|
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1661
1663
|
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1662
|
-
expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list" });
|
|
1664
|
+
expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list", entityId: "x" });
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
// #2416: entityEdit-create passes the newly created record's id through to
|
|
1668
|
+
// the post-submit navigate as `entityId` (extractCreatedId); actionForm's
|
|
1669
|
+
// redirect did the same navigate but never extracted the id. The with-id
|
|
1670
|
+
// case is covered by the actionForm redirect success test above (now
|
|
1671
|
+
// asserting entityId); this pins the back-compat guarantee.
|
|
1672
|
+
test("actionForm mit redirect: Handler-Result ohne id → navigate ohne entityId-Key (Rückwärtskompatibilität, #2416)", async () => {
|
|
1673
|
+
const navigateCalls: NavTarget[] = [];
|
|
1674
|
+
const dispatcher = makeDispatcher({
|
|
1675
|
+
write: (async () => ({
|
|
1676
|
+
isSuccess: true,
|
|
1677
|
+
data: {},
|
|
1678
|
+
})) as unknown as Dispatcher["write"],
|
|
1679
|
+
});
|
|
1680
|
+
const memoryNav = {
|
|
1681
|
+
route: { screenId: "quick-add" },
|
|
1682
|
+
navigate: (target: NavTarget) => {
|
|
1683
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1684
|
+
},
|
|
1685
|
+
replace: () => undefined,
|
|
1686
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1687
|
+
searchParams: {},
|
|
1688
|
+
setSearchParams: () => undefined,
|
|
1689
|
+
};
|
|
1690
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
1691
|
+
id: "quick-add",
|
|
1692
|
+
type: "actionForm",
|
|
1693
|
+
handler: "tasks:write:task:quick-add",
|
|
1694
|
+
fields: { title: { type: "text", required: true } },
|
|
1695
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
1696
|
+
redirect: "task-list",
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
1700
|
+
render(
|
|
1701
|
+
<NavProvider value={memoryNav}>
|
|
1702
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1703
|
+
<KumikoScreen
|
|
1704
|
+
schema={{ ...schema, screens: [actionScreen, listScreen] }}
|
|
1705
|
+
qn="tasks:screen:quick-add"
|
|
1706
|
+
/>
|
|
1707
|
+
</DispatcherProvider>
|
|
1708
|
+
</NavProvider>,
|
|
1709
|
+
);
|
|
1710
|
+
|
|
1711
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1712
|
+
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1713
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1714
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1715
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
|
|
1716
|
+
expect(Object.hasOwn(navigateCalls[0] ?? {}, "entityId")).toBe(false);
|
|
1663
1717
|
});
|
|
1664
1718
|
|
|
1665
1719
|
// cancelTarget (Bug-Bash 2026-06-07, Bug 9): redirect erzeugte
|
|
@@ -283,7 +283,7 @@ describe("RenderEdit", () => {
|
|
|
283
283
|
expect(seenResults[0]?.isSuccess).toBe(true);
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
-
test("layout.width defaults the form shell to max-w-
|
|
286
|
+
test("layout.width defaults the form shell to max-w-full when unset", () => {
|
|
287
287
|
render(
|
|
288
288
|
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
289
289
|
<RenderEdit<TestValues>
|
|
@@ -297,16 +297,16 @@ describe("RenderEdit", () => {
|
|
|
297
297
|
);
|
|
298
298
|
|
|
299
299
|
const shell = screen.getByTestId("render-edit-form").firstElementChild;
|
|
300
|
-
expect(shell?.className).toContain("max-w-
|
|
301
|
-
expect(shell?.className).not.toContain("max-w-
|
|
300
|
+
expect(shell?.className).toContain("max-w-full");
|
|
301
|
+
expect(shell?.className).not.toContain("max-w-3xl");
|
|
302
302
|
});
|
|
303
303
|
|
|
304
|
-
test("layout.width: '
|
|
304
|
+
test("layout.width: '3xl' narrows the form shell when set", () => {
|
|
305
305
|
const screenDef = makeScreen();
|
|
306
306
|
render(
|
|
307
307
|
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
308
308
|
<RenderEdit<TestValues>
|
|
309
|
-
screen={{ ...screenDef, layout: { ...screenDef.layout, width: "
|
|
309
|
+
screen={{ ...screenDef, layout: { ...screenDef.layout, width: "3xl" } }}
|
|
310
310
|
entity={orderEntity}
|
|
311
311
|
featureName="orders"
|
|
312
312
|
initial={{ title: "", count: 0, isUrgent: false }}
|
|
@@ -316,8 +316,8 @@ describe("RenderEdit", () => {
|
|
|
316
316
|
);
|
|
317
317
|
|
|
318
318
|
const shell = screen.getByTestId("render-edit-form").firstElementChild;
|
|
319
|
-
expect(shell?.className).toContain("max-w-
|
|
320
|
-
expect(shell?.className).not.toContain("max-w-
|
|
319
|
+
expect(shell?.className).toContain("max-w-3xl");
|
|
320
|
+
expect(shell?.className).not.toContain("max-w-full");
|
|
321
321
|
});
|
|
322
322
|
|
|
323
323
|
test("title resolved aus i18n-Key `screen:<id>.title` mit screenId als Fallback", () => {
|
package/src/primitives/index.tsx
CHANGED
|
@@ -673,8 +673,8 @@ function DefaultDataTable({
|
|
|
673
673
|
// scrollen typischerweise mit dem Page-Container, nicht intern.
|
|
674
674
|
// Sticky würde mit der Topbar konkurrieren.
|
|
675
675
|
const hasTableActions = rowActions !== undefined && rowActions.length > 0;
|
|
676
|
-
|
|
677
|
-
rows.length === 0 ? (
|
|
676
|
+
function tableInner(): ReactNode {
|
|
677
|
+
return rows.length === 0 ? (
|
|
678
678
|
<div
|
|
679
679
|
data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
|
|
680
680
|
className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
|
|
@@ -777,6 +777,8 @@ function DefaultDataTable({
|
|
|
777
777
|
</Table>
|
|
778
778
|
</div>
|
|
779
779
|
);
|
|
780
|
+
}
|
|
781
|
+
const tableContent = tableInner();
|
|
780
782
|
|
|
781
783
|
// Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
|
|
782
784
|
// damit der User bei einem Filter-Hit-of-Zero zurückblättern kann
|
|
@@ -1620,13 +1622,9 @@ function DefaultForm({
|
|
|
1620
1622
|
);
|
|
1621
1623
|
}
|
|
1622
1624
|
|
|
1623
|
-
//
|
|
1624
|
-
//
|
|
1625
|
-
//
|
|
1626
|
-
// bg-muted/30 als Farb-Trenner statt harter Linie. main hat kein Padding;
|
|
1627
|
-
// der max-w-3xl-Body rahmt die Card, mx-auto zentriert ihn im Main statt
|
|
1628
|
-
// linksbündig (sonst „eingesperrt" mit leerer rechter Hälfte auf breiten
|
|
1629
|
-
// Screens).
|
|
1625
|
+
// One card form: title as header (no divider under it), sections divided
|
|
1626
|
+
// between each other, muted action footer. Shell width defaults to full
|
|
1627
|
+
// (same chrome as lists); pass width to narrow (auth-adjacent / dense).
|
|
1630
1628
|
return (
|
|
1631
1629
|
<form
|
|
1632
1630
|
onSubmit={(e) => {
|
|
@@ -1698,17 +1696,13 @@ function DefaultForm({
|
|
|
1698
1696
|
);
|
|
1699
1697
|
}
|
|
1700
1698
|
|
|
1701
|
-
//
|
|
1702
|
-
//
|
|
1703
|
-
// (
|
|
1704
|
-
//
|
|
1705
|
-
// beliebiger max-w-*-Overrides: sm=schmale Auth-Forms, 3xl=Standard-Detail,
|
|
1706
|
-
// 4xl=tabellen-nahe Forms, full=volle Breite. Inhalt nutzt Card-Primitives;
|
|
1707
|
-
// `className` (z.B. "flex flex-col gap-6") für Multi-Card-Stacks.
|
|
1699
|
+
// Canonical form/settings shell shared by DefaultForm (configEdit/entityEdit)
|
|
1700
|
+
// and custom settings screens (profile, privacy-center). Default width matches
|
|
1701
|
+
// list chrome (full); override via maxWidth / layout.width when a screen
|
|
1702
|
+
// truly needs a narrow column (sm=auth-adjacent, 3xl/4xl=centered detail).
|
|
1708
1703
|
export type FormScreenShellWidth = FormWidth;
|
|
1709
1704
|
|
|
1710
1705
|
const formScreenShellWidth: Record<FormScreenShellWidth, string> = {
|
|
1711
|
-
sm: "max-w-sm mx-auto",
|
|
1712
1706
|
"3xl": "max-w-3xl mx-auto",
|
|
1713
1707
|
"4xl": "max-w-4xl mx-auto",
|
|
1714
1708
|
full: "max-w-full",
|
|
@@ -1718,7 +1712,7 @@ export function FormScreenShell({
|
|
|
1718
1712
|
children,
|
|
1719
1713
|
className,
|
|
1720
1714
|
testId,
|
|
1721
|
-
maxWidth = "
|
|
1715
|
+
maxWidth = "full",
|
|
1722
1716
|
}: {
|
|
1723
1717
|
readonly children: ReactNode;
|
|
1724
1718
|
readonly className?: string;
|
|
@@ -20,12 +20,6 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
|
|
|
20
20
|
/** Must derive from row content (e.g. `row.id`), not from `index` — a
|
|
21
21
|
* live refresh reorders rows (new/changed rows move to the front). */
|
|
22
22
|
readonly rowId: (row: TRow, index: number) => string;
|
|
23
|
-
/** Pass this when `rowId` depends on list position — the live-merge path
|
|
24
|
-
* needs a position-independent identity. Without it, an index-based
|
|
25
|
-
* `rowId` (legitimate when live-merge isn't used) can collide between
|
|
26
|
-
* the freshly-fetched first page's local indices and the accumulated
|
|
27
|
-
* list's indices, silently dropping an unrelated mid-list row on merge. */
|
|
28
|
-
readonly rowKey?: (row: TRow) => string;
|
|
29
23
|
readonly renderRow: (row: TRow) => ReactNode;
|
|
30
24
|
readonly emptyState?: ReactNode;
|
|
31
25
|
readonly className?: string;
|
|
@@ -55,7 +49,6 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
55
49
|
rows,
|
|
56
50
|
nextCursor,
|
|
57
51
|
rowId,
|
|
58
|
-
rowKey,
|
|
59
52
|
renderRow,
|
|
60
53
|
emptyState,
|
|
61
54
|
className,
|
|
@@ -80,16 +73,6 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
80
73
|
nextCursorRef.current = nextCursor;
|
|
81
74
|
const rowIdRef = useRef(rowId);
|
|
82
75
|
rowIdRef.current = rowId;
|
|
83
|
-
const rowKeyRef = useRef(rowKey);
|
|
84
|
-
rowKeyRef.current = rowKey;
|
|
85
|
-
// Position-independent when rowKey is supplied, otherwise falls back to
|
|
86
|
-
// the (possibly position-dependent) rowId — same identity function used
|
|
87
|
-
// for both sides of the live-merge diff below so a position-based rowId
|
|
88
|
-
// can no longer collide across the fresh page's local indices and the
|
|
89
|
-
// accumulated list's indices (fw#1829).
|
|
90
|
-
const identifyRef = useRef((row: TRow, index: number): string =>
|
|
91
|
-
rowKeyRef.current !== undefined ? rowKeyRef.current(row) : rowIdRef.current(row, index),
|
|
92
|
-
);
|
|
93
76
|
|
|
94
77
|
// Discards a response whose request was superseded by a newer one before
|
|
95
78
|
// it resolved (e.g. two searches fired in quick succession) — without
|
|
@@ -121,9 +104,8 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
121
104
|
if (cursor === null) {
|
|
122
105
|
// Same identity as refreshFirstPage's freshIds/staleRows diff below
|
|
123
106
|
// — this ref is what that diff's previousFirstPageIds reads, so it
|
|
124
|
-
// must agree on rowKey vs rowId or the two id spaces never overlap.
|
|
125
107
|
firstPageIdsRef.current = new Set(
|
|
126
|
-
nextRows.map((row, index) =>
|
|
108
|
+
nextRows.map((row, index) => rowIdRef.current(row, index)),
|
|
127
109
|
);
|
|
128
110
|
}
|
|
129
111
|
setState((prev) => {
|
|
@@ -174,7 +156,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
174
156
|
// skip: background live refresh failed, keep showing the current rows
|
|
175
157
|
if (!res.isSuccess) return;
|
|
176
158
|
const freshRows = rowsRef.current(res.data);
|
|
177
|
-
const freshIds = new Set(freshRows.map((row, index) =>
|
|
159
|
+
const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
|
|
178
160
|
// A row absent from freshIds is stale for one of two reasons: it was
|
|
179
161
|
// dropped from page 1 (deleted, or filtered out elsewhere) and must be
|
|
180
162
|
// pruned, or it belongs to an already-accumulated later page and must
|
|
@@ -194,7 +176,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
194
176
|
return { kind: "ready", rows: freshRows, cursor: nextCursorRef.current(res.data) };
|
|
195
177
|
}
|
|
196
178
|
const staleRows = prev.rows.filter((row, index) => {
|
|
197
|
-
const id =
|
|
179
|
+
const id = rowIdRef.current(row, index);
|
|
198
180
|
return !freshIds.has(id) && !previousFirstPageIds.has(id);
|
|
199
181
|
});
|
|
200
182
|
return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
|