@cosmicdrift/kumiko-renderer 0.208.1 → 0.208.2
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",
|
|
3
|
-
"version": "0.208.
|
|
3
|
+
"version": "0.208.2",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.208.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.208.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.208.2",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.208.2",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@testing-library/react": "^16.3.2",
|
|
26
26
|
"@types/react": "^19.2.14",
|
|
27
27
|
"jsdom": "^29.1.1",
|
|
28
|
-
"@cosmicdrift/kumiko-locale-de": "0.208.
|
|
28
|
+
"@cosmicdrift/kumiko-locale-de": "0.208.2"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// fw#2216: a projectionList query handler that doesn't honor the PagedRows
|
|
2
|
+
// contract ({ rows, nextCursor }) used to fall through `rowsQuery.data?.rows
|
|
3
|
+
// ?? []` and silently render an empty table with HTTP 200 (prod bug on
|
|
4
|
+
// /session-list). This renders the real path (KumikoScreen →
|
|
5
|
+
// ProjectionListBody → RenderList) under a stub dispatcher returning a bare
|
|
6
|
+
// array, and proves the renderer-level shape guard surfaces an error banner
|
|
7
|
+
// instead of an empty list — plus that a correctly-shaped response still
|
|
8
|
+
// renders rows.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
12
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
13
|
+
import { render, waitFor } from "@testing-library/react";
|
|
14
|
+
import type { ComponentType, ReactNode } from "react";
|
|
15
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
16
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
17
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
18
|
+
import {
|
|
19
|
+
type BannerProps,
|
|
20
|
+
type CorePrimitives,
|
|
21
|
+
type DataTableProps,
|
|
22
|
+
PrimitivesProvider,
|
|
23
|
+
} from "../../primitives";
|
|
24
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
25
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
26
|
+
import type { NavApi } from "../nav";
|
|
27
|
+
import { NavProvider } from "../nav";
|
|
28
|
+
|
|
29
|
+
let capturedProps: DataTableProps | undefined;
|
|
30
|
+
const captureDataTable: ComponentType<DataTableProps> = (props) => {
|
|
31
|
+
capturedProps = props;
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
const getCapturedProps = (): DataTableProps | undefined => capturedProps;
|
|
35
|
+
const noop = (): ReactNode => null;
|
|
36
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
37
|
+
|
|
38
|
+
// passChildren would drop testId — the bad-shape test asserts on which
|
|
39
|
+
// banner rendered, so it needs a real (if minimal) wrapper element.
|
|
40
|
+
const TestBanner: ComponentType<BannerProps> = ({ children, testId }) => (
|
|
41
|
+
<div data-testid={testId}>{children}</div>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const testPrimitives: CorePrimitives = {
|
|
45
|
+
Button: noop,
|
|
46
|
+
Banner: TestBanner,
|
|
47
|
+
Field: passChildren,
|
|
48
|
+
Input: noop,
|
|
49
|
+
DataTable: captureDataTable,
|
|
50
|
+
Form: passChildren,
|
|
51
|
+
Section: passChildren,
|
|
52
|
+
Card: passChildren,
|
|
53
|
+
Grid: passChildren,
|
|
54
|
+
GridCell: passChildren,
|
|
55
|
+
Text: passChildren,
|
|
56
|
+
Heading: noop,
|
|
57
|
+
Dialog: noop,
|
|
58
|
+
Modal: noop,
|
|
59
|
+
Lightbox: noop,
|
|
60
|
+
ConfigSourceBadge: noop,
|
|
61
|
+
ConfigCascadeView: noop,
|
|
62
|
+
Link: noop,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function stubDispatcher(queryResult: unknown): Dispatcher {
|
|
66
|
+
return {
|
|
67
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
68
|
+
query: (async () => ({ isSuccess: true, data: queryResult })) as unknown as Dispatcher["query"],
|
|
69
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
70
|
+
statusStore: {
|
|
71
|
+
getState: () => "online",
|
|
72
|
+
subscribe: () => () => {},
|
|
73
|
+
} as unknown as Dispatcher["statusStore"],
|
|
74
|
+
async *stream() {},
|
|
75
|
+
pendingWrites: () => [],
|
|
76
|
+
pendingFiles: () => [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildSchema(screen: ProjectionListScreenDefinition): FeatureSchema {
|
|
81
|
+
return {
|
|
82
|
+
featureName: "ledger",
|
|
83
|
+
entities: {},
|
|
84
|
+
screens: [screen],
|
|
85
|
+
} as FeatureSchema;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const nav: NavApi = {
|
|
89
|
+
route: { screenId: "ledger:screen:schedule-list" },
|
|
90
|
+
navigate: () => {},
|
|
91
|
+
replace: () => {},
|
|
92
|
+
hrefFor: () => "",
|
|
93
|
+
searchParams: {},
|
|
94
|
+
setSearchParams: () => {},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function renderProjectionList(
|
|
98
|
+
screen: ProjectionListScreenDefinition,
|
|
99
|
+
queryResult: unknown,
|
|
100
|
+
): ReturnType<typeof render> {
|
|
101
|
+
return render(
|
|
102
|
+
<LocaleProvider
|
|
103
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
104
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
105
|
+
>
|
|
106
|
+
<DispatcherProvider dispatcher={stubDispatcher(queryResult)}>
|
|
107
|
+
<NavProvider value={nav}>
|
|
108
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
109
|
+
<KumikoScreen schema={buildSchema(screen)} qn="ledger:screen:schedule-list" />
|
|
110
|
+
</PrimitivesProvider>
|
|
111
|
+
</NavProvider>
|
|
112
|
+
</DispatcherProvider>
|
|
113
|
+
</LocaleProvider>,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const screen: ProjectionListScreenDefinition = {
|
|
118
|
+
id: "schedule-list",
|
|
119
|
+
type: "projectionList",
|
|
120
|
+
query: "ledger:query:schedule:list",
|
|
121
|
+
columns: ["description"],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
describe("ProjectionListBody — PagedRows shape guard (fw#2216)", () => {
|
|
125
|
+
test("a bare-array query response renders the bad-shape error banner, not an empty table", async () => {
|
|
126
|
+
capturedProps = undefined;
|
|
127
|
+
const { container } = renderProjectionList(screen, [{ id: "1", description: "acme" }]);
|
|
128
|
+
|
|
129
|
+
const banner = await waitFor(() => {
|
|
130
|
+
const el = container.querySelector('[data-testid="kumiko-screen-projection-list-bad-shape"]');
|
|
131
|
+
if (el === null) throw new Error("bad-shape banner not rendered");
|
|
132
|
+
return el;
|
|
133
|
+
});
|
|
134
|
+
expect(banner.textContent).toContain("schedule-list");
|
|
135
|
+
expect(banner.textContent).toContain("ledger:query:schedule:list");
|
|
136
|
+
expect(getCapturedProps()).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a correct { rows, nextCursor } response renders the rows, no error banner", async () => {
|
|
140
|
+
capturedProps = undefined;
|
|
141
|
+
const { container } = renderProjectionList(screen, {
|
|
142
|
+
rows: [{ id: "1", description: "acme" }],
|
|
143
|
+
nextCursor: null,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await waitFor(() => expect(getCapturedProps()).toBeDefined());
|
|
147
|
+
expect(getCapturedProps()?.rows).toHaveLength(1);
|
|
148
|
+
expect(
|
|
149
|
+
container.querySelector('[data-testid="kumiko-screen-projection-list-bad-shape"]'),
|
|
150
|
+
).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -1380,7 +1380,7 @@ function ProjectionListBody({
|
|
|
1380
1380
|
readonly translate?: Translate;
|
|
1381
1381
|
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
1382
1382
|
}): ReactNode {
|
|
1383
|
-
const { Banner } = usePrimitives();
|
|
1383
|
+
const { Banner, Text } = usePrimitives();
|
|
1384
1384
|
const t = useTranslation();
|
|
1385
1385
|
const nav = useNav();
|
|
1386
1386
|
const dispatcher = useOptionalDispatcher();
|
|
@@ -1560,6 +1560,21 @@ function ProjectionListBody({
|
|
|
1560
1560
|
);
|
|
1561
1561
|
}
|
|
1562
1562
|
|
|
1563
|
+
// rowsQuery.data is typed as PagedRows but the query handler is free to
|
|
1564
|
+
// return anything at runtime (fw#2216: a bare array silently rendered an
|
|
1565
|
+
// empty table instead of surfacing the mismatch); a system-boundary cast
|
|
1566
|
+
// is needed to inspect the actual wire shape before trusting it.
|
|
1567
|
+
const rawRowsData = rowsQuery.data as unknown as { readonly rows?: unknown } | null;
|
|
1568
|
+
if (rawRowsData !== null && !Array.isArray(rawRowsData.rows)) {
|
|
1569
|
+
return (
|
|
1570
|
+
<Banner padded variant="error" testId="kumiko-screen-projection-list-bad-shape">
|
|
1571
|
+
Screen <Text variant="code">{screen.id}</Text> query{" "}
|
|
1572
|
+
<Text variant="code">{screen.query}</Text> did not return a PagedRows envelope; a
|
|
1573
|
+
projectionList query must return <Text variant="code">{"{ rows, nextCursor }"}</Text>.
|
|
1574
|
+
</Banner>
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1563
1578
|
const rowClickAction = screen.rowActions?.find(
|
|
1564
1579
|
(a): a is RowActionNavigate => a.kind === "navigate" && a.rowClick === true,
|
|
1565
1580
|
);
|