@cosmicdrift/kumiko-renderer-web 0.165.0 → 2.0.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 +4 -4
- package/src/__tests__/xss-safety.test.tsx +4 -0
- package/src/index.ts +4 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +5 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/index.tsx +14 -18
- package/src/primitives/money-input.tsx +20 -10
- package/src/ui/__tests__/avatar.test.tsx +4 -2
- package/src/version/__tests__/update-checker.test.tsx +3 -2
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/result-panel.test.tsx +22 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/index.ts +2 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/result-panel.tsx +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.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": "2.0.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "2.0.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "2.0.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",
|
|
@@ -16,6 +16,10 @@ const PAYLOAD = '<script>window.__xss = true;</script><img src=x onerror="window
|
|
|
16
16
|
const UNSAFE_HREFS = [
|
|
17
17
|
"javascript:window.__xss = true",
|
|
18
18
|
"data:text/html,<script>window.__xss=true</script>",
|
|
19
|
+
// Browsers strip embedded control/whitespace chars before scheme
|
|
20
|
+
// detection — a tab/newline mid-scheme must not bypass the check.
|
|
21
|
+
"java\tscript:window.__xss = true",
|
|
22
|
+
"java\nscript:window.__xss = true",
|
|
19
23
|
];
|
|
20
24
|
|
|
21
25
|
describe("XSS-safety (tenant-authored content)", () => {
|
package/src/index.ts
CHANGED
|
@@ -163,8 +163,10 @@ export type {
|
|
|
163
163
|
BooleanFieldProps,
|
|
164
164
|
ComparisonMetric,
|
|
165
165
|
DateFieldProps,
|
|
166
|
+
DrawerProps,
|
|
166
167
|
FeedRow,
|
|
167
168
|
FileFieldProps,
|
|
169
|
+
InfinityListProps,
|
|
168
170
|
NumberFieldProps,
|
|
169
171
|
ProgressListRow,
|
|
170
172
|
QueryTableColumn,
|
|
@@ -188,10 +190,12 @@ export {
|
|
|
188
190
|
ComparisonTable,
|
|
189
191
|
DateField,
|
|
190
192
|
DetailList,
|
|
193
|
+
Drawer,
|
|
191
194
|
EmptyState,
|
|
192
195
|
ErrorState,
|
|
193
196
|
FeedList,
|
|
194
197
|
FileField,
|
|
198
|
+
InfinityList,
|
|
195
199
|
LoadingState,
|
|
196
200
|
MiniStat,
|
|
197
201
|
ModeSwitch,
|
|
@@ -186,4 +186,9 @@ describe("defaultCellRender", () => {
|
|
|
186
186
|
test("money → unerwarteter Value-Shape fällt auf String zurück", () => {
|
|
187
187
|
expect(defaultCellRender(45000, "money")).toBe("45000");
|
|
188
188
|
});
|
|
189
|
+
|
|
190
|
+
test("money → invalid currency does not throw, falls back to [object Object] like pre-regression (#1494)", () => {
|
|
191
|
+
expect(() => defaultCellRender({ amount: 1, currency: "" }, "money")).not.toThrow();
|
|
192
|
+
expect(defaultCellRender({ amount: 1, currency: "" }, "money")).toBe("[object Object]");
|
|
193
|
+
});
|
|
189
194
|
});
|
|
@@ -8,7 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, mock, test } from "bun:test";
|
|
10
10
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
11
|
-
import { currencyDecimals, MoneyInput, parseLocaleNumber } from "../money-input";
|
|
11
|
+
import { currencyDecimals, formatMoney, MoneyInput, parseLocaleNumber } from "../money-input";
|
|
12
|
+
|
|
13
|
+
describe("formatMoney", () => {
|
|
14
|
+
test("formats a normal amount with the currency symbol", () => {
|
|
15
|
+
expect(formatMoney(199_99, "USD", "en-US")).toBe("$199.99");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("falls back to the raw amount instead of throwing on an invalid currency (#1494)", () => {
|
|
19
|
+
expect(formatMoney(100, "", "en-US")).toBe("100");
|
|
20
|
+
expect(formatMoney(100, "EU", "en-US")).toBe("100");
|
|
21
|
+
expect(formatMoney(100, "€", "en-US")).toBe("100");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
12
24
|
|
|
13
25
|
describe("currencyDecimals", () => {
|
|
14
26
|
test("0-Decimal-Währungen (JPY/KRW/VND/ISK)", () => {
|
package/src/primitives/index.tsx
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Dropdown etc. kommen später).
|
|
10
10
|
|
|
11
11
|
import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
|
|
12
|
-
import { applyFormatSpec } from "@cosmicdrift/kumiko-headless";
|
|
12
|
+
import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
13
13
|
import type {
|
|
14
14
|
DataTableRowAction,
|
|
15
15
|
DataTableRowActionMode,
|
|
@@ -79,7 +79,7 @@ import {
|
|
|
79
79
|
import { FileUploadInput } from "./file-upload";
|
|
80
80
|
import { DefaultLightbox } from "./lightbox";
|
|
81
81
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
82
|
-
import {
|
|
82
|
+
import { formatMoney, MoneyInput } from "./money-input";
|
|
83
83
|
import { TimestampInput } from "./timestamp-input";
|
|
84
84
|
import { useToast } from "./toast";
|
|
85
85
|
|
|
@@ -1261,7 +1261,8 @@ function isMoneyValue(value: unknown): value is { amount: number; currency: stri
|
|
|
1261
1261
|
typeof value === "object" &&
|
|
1262
1262
|
value !== null &&
|
|
1263
1263
|
typeof (value as Record<string, unknown>)["amount"] === "number" &&
|
|
1264
|
-
typeof (value as Record<string, unknown>)["currency"] === "string"
|
|
1264
|
+
typeof (value as Record<string, unknown>)["currency"] === "string" &&
|
|
1265
|
+
/^[A-Za-z]{3}$/.test((value as Record<string, unknown>)["currency"] as string)
|
|
1265
1266
|
);
|
|
1266
1267
|
}
|
|
1267
1268
|
|
|
@@ -1284,13 +1285,13 @@ export function defaultCellRender(
|
|
|
1284
1285
|
if (type === "timestamp" || type === "date") return applyFormatSpec({ format: type }, value);
|
|
1285
1286
|
if (type === "money") {
|
|
1286
1287
|
if (!isMoneyValue(value)) return String(value);
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1288
|
+
// No `locale` param here: DataTableCell has no app-locale context to
|
|
1289
|
+
// thread through (unlike the form path — see MoneyInput/RenderField in
|
|
1290
|
+
// packages/renderer, which pins the LocaleProvider locale). formatMoney
|
|
1291
|
+
// falls back to guessLocale() (navigator.language), so the same amount
|
|
1292
|
+
// can render differently in a table vs. a form on the same screen.
|
|
1293
|
+
// Mid-term: pass the app locale down here too, analogous to render-field.
|
|
1294
|
+
return formatMoney(value.amount, value.currency);
|
|
1294
1295
|
}
|
|
1295
1296
|
if (type === "select") {
|
|
1296
1297
|
const raw = String(value);
|
|
@@ -1681,14 +1682,9 @@ function DefaultText({ variant = "body", children, testId }: TextProps): ReactNo
|
|
|
1681
1682
|
|
|
1682
1683
|
// ---- Link (anchor mit Button-/Muted-Optik) ----
|
|
1683
1684
|
|
|
1684
|
-
//
|
|
1685
|
-
//
|
|
1686
|
-
//
|
|
1687
|
-
function isSafeHref(href: string): boolean {
|
|
1688
|
-
const trimmed = href.trim().toLowerCase();
|
|
1689
|
-
if (!/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return true;
|
|
1690
|
-
return /^(?:https?|mailto):/.test(trimmed);
|
|
1691
|
-
}
|
|
1685
|
+
// isSafeHref lives in @cosmicdrift/kumiko-headless — shared with
|
|
1686
|
+
// page-render's server-side markdown renderer, both take untrusted
|
|
1687
|
+
// tenant-authored hrefs.
|
|
1692
1688
|
|
|
1693
1689
|
// `button` nutzt die Primary-Buttonfläche auf einem semantischen <a> —
|
|
1694
1690
|
// der Standard für „weiter zu"-Navigationen nach Success-States (ehem.
|
|
@@ -64,16 +64,7 @@ export function MoneyInput({
|
|
|
64
64
|
const [draft, setDraft] = useState<string>("");
|
|
65
65
|
|
|
66
66
|
const major = value === "" ? null : value / factor;
|
|
67
|
-
|
|
68
|
-
const formatted =
|
|
69
|
-
major === null
|
|
70
|
-
? ""
|
|
71
|
-
: new Intl.NumberFormat(resolvedLocale, {
|
|
72
|
-
style: "currency",
|
|
73
|
-
currency,
|
|
74
|
-
minimumFractionDigits: decimals,
|
|
75
|
-
maximumFractionDigits: decimals,
|
|
76
|
-
}).format(major);
|
|
67
|
+
const formatted = value === "" ? "" : formatMoney(value, currency, resolvedLocale);
|
|
77
68
|
|
|
78
69
|
// Edit-Mode: Decimal-String ohne Tausender-Trenner. Konsistente Helper-
|
|
79
70
|
// Funktion damit Focus-Init und Render-Fallback nicht auseinanderdriften.
|
|
@@ -170,6 +161,25 @@ function guessLocale(): string {
|
|
|
170
161
|
return "en-US";
|
|
171
162
|
}
|
|
172
163
|
|
|
164
|
+
// Shared with defaultCellRender (index.tsx) so both formatting paths stay
|
|
165
|
+
// identical instead of drifting. currency is validated against the
|
|
166
|
+
// 3-letter-alpha shape Intl.NumberFormat requires — a non-conforming
|
|
167
|
+
// value (e.g. from a JSONB custom field that never ran through
|
|
168
|
+
// rehydrateMoney) throws a RangeError inside Intl.NumberFormat, and with
|
|
169
|
+
// no ErrorBoundary in this render tree that would take down the whole
|
|
170
|
+
// page instead of just this cell.
|
|
171
|
+
export function formatMoney(amountMinor: number, currency: string, locale?: string): string {
|
|
172
|
+
if (!/^[A-Za-z]{3}$/.test(currency)) return String(amountMinor);
|
|
173
|
+
const decimals = currencyDecimals(currency);
|
|
174
|
+
const resolvedLocale = locale ?? guessLocale();
|
|
175
|
+
return new Intl.NumberFormat(resolvedLocale, {
|
|
176
|
+
style: "currency",
|
|
177
|
+
currency,
|
|
178
|
+
minimumFractionDigits: decimals,
|
|
179
|
+
maximumFractionDigits: decimals,
|
|
180
|
+
}).format(amountMinor / 10 ** decimals);
|
|
181
|
+
}
|
|
182
|
+
|
|
173
183
|
// Locale-Decimal-Parse: erkennt automatisch ob Komma oder Punkt der
|
|
174
184
|
// Decimal-Separator ist. Intl.NumberFormat liefert die Trenner für
|
|
175
185
|
// das Locale, daraus bauen wir den Reverse-Parser. Strict beim
|
|
@@ -20,16 +20,18 @@ describe("ui/Avatar", () => {
|
|
|
20
20
|
expect(document.querySelector('[data-slot="avatar"]')).not.toBeNull();
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test("
|
|
23
|
+
test("fallback renders while image is unloaded", () => {
|
|
24
24
|
render(
|
|
25
25
|
<Avatar>
|
|
26
26
|
<AvatarImage src="/photo.png" alt="User" />
|
|
27
27
|
<AvatarFallback>FB</AvatarFallback>
|
|
28
28
|
</Avatar>,
|
|
29
29
|
);
|
|
30
|
-
// happy-dom
|
|
30
|
+
// happy-dom never loads images, so AvatarImage never reaches "loaded" —
|
|
31
|
+
// this only exercises the unloaded state, not an actual load failure.
|
|
31
32
|
expect(screen.getByText("FB")).toBeTruthy();
|
|
32
33
|
expect(document.querySelector('[data-slot="avatar-fallback"]')).not.toBeNull();
|
|
34
|
+
expect(document.querySelector('[data-slot="avatar-image"]')).toBeNull();
|
|
33
35
|
});
|
|
34
36
|
|
|
35
37
|
test("AvatarBadge renders inside avatar", () => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// shouldShowUpdate/isKumikoBuild stay in update-checker.test.ts (unit).
|
|
3
3
|
|
|
4
4
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
5
|
-
import { screen, waitFor } from "@testing-library/react";
|
|
5
|
+
import { act, screen, waitFor } from "@testing-library/react";
|
|
6
6
|
import { render } from "../../__tests__/test-utils";
|
|
7
7
|
import { UpdateChecker } from "../update-checker";
|
|
8
8
|
|
|
@@ -99,7 +99,7 @@ describe("UpdateChecker", () => {
|
|
|
99
99
|
expect(screen.queryByRole("status")).toBeNull();
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
test("no __KUMIKO_BUILD__ → no fetch, no banner", () => {
|
|
102
|
+
test("no __KUMIKO_BUILD__ → no fetch, no banner", async () => {
|
|
103
103
|
const fetchSpy = mock(async () => ({
|
|
104
104
|
ok: true,
|
|
105
105
|
json: async () => ({ id: "other", builtAt: "" }),
|
|
@@ -107,6 +107,7 @@ describe("UpdateChecker", () => {
|
|
|
107
107
|
globalThis.fetch = fetchSpy as typeof globalThis.fetch;
|
|
108
108
|
|
|
109
109
|
render(<UpdateChecker />);
|
|
110
|
+
await act(async () => {});
|
|
110
111
|
|
|
111
112
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
112
113
|
expect(screen.queryByRole("status")).toBeNull();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { fireEvent, render, screen } from "../../__tests__/test-utils";
|
|
3
|
+
import { Drawer } from "../drawer";
|
|
4
|
+
|
|
5
|
+
describe("Drawer", () => {
|
|
6
|
+
test("rendert Titel + Inhalt wenn open", () => {
|
|
7
|
+
render(
|
|
8
|
+
<Drawer open={true} onOpenChange={() => {}} title="Mail" testId="drawer">
|
|
9
|
+
<div>Body</div>
|
|
10
|
+
</Drawer>,
|
|
11
|
+
);
|
|
12
|
+
expect(screen.getByText("Mail")).toBeTruthy();
|
|
13
|
+
expect(screen.getByText("Body")).toBeTruthy();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("open=false rendert nichts", () => {
|
|
17
|
+
render(
|
|
18
|
+
<Drawer open={false} onOpenChange={() => {}} title="Mail">
|
|
19
|
+
<div>Body</div>
|
|
20
|
+
</Drawer>,
|
|
21
|
+
);
|
|
22
|
+
expect(screen.queryByText("Body")).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("Escape ruft onOpenChange(false)", () => {
|
|
26
|
+
const onOpenChange = mock((_open: boolean) => {});
|
|
27
|
+
render(
|
|
28
|
+
<Drawer open={true} onOpenChange={onOpenChange} title="Mail" testId="drawer">
|
|
29
|
+
<div>Body</div>
|
|
30
|
+
</Drawer>,
|
|
31
|
+
);
|
|
32
|
+
fireEvent.keyDown(screen.getByTestId("drawer"), { key: "Escape" });
|
|
33
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import {
|
|
6
|
+
createMockDispatcher,
|
|
7
|
+
fireEvent,
|
|
8
|
+
render,
|
|
9
|
+
screen,
|
|
10
|
+
waitFor,
|
|
11
|
+
} from "../../__tests__/test-utils";
|
|
12
|
+
import { InfinityList } from "../infinity-list";
|
|
13
|
+
|
|
14
|
+
// jsdom has no IntersectionObserver — stub stores the callback per observer
|
|
15
|
+
// so tests can fire the "sentinel became visible" event manually instead of
|
|
16
|
+
// simulating real scrolling.
|
|
17
|
+
const observers: ((entries: readonly { isIntersecting: boolean }[]) => void)[] = [];
|
|
18
|
+
globalThis.IntersectionObserver = class {
|
|
19
|
+
constructor(cb: (entries: readonly { isIntersecting: boolean }[]) => void) {
|
|
20
|
+
observers.push(cb);
|
|
21
|
+
}
|
|
22
|
+
observe(): void {}
|
|
23
|
+
unobserve(): void {}
|
|
24
|
+
disconnect(): void {}
|
|
25
|
+
} as unknown as typeof IntersectionObserver;
|
|
26
|
+
|
|
27
|
+
function fireIntersect(): void {
|
|
28
|
+
observers[observers.length - 1]?.([{ isIntersecting: true }]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderWithDispatcher(ui: ReactNode, dispatcher: Dispatcher) {
|
|
32
|
+
return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Row = { readonly id: string; readonly subject: string };
|
|
36
|
+
type Page = { readonly rows: readonly Row[]; readonly nextCursor: string | null };
|
|
37
|
+
|
|
38
|
+
function list(query: string) {
|
|
39
|
+
return (
|
|
40
|
+
<InfinityList<Page, Row>
|
|
41
|
+
query={query}
|
|
42
|
+
rows={(data) => data.rows}
|
|
43
|
+
nextCursor={(data) => data.nextCursor}
|
|
44
|
+
rowId={(row) => row.id}
|
|
45
|
+
renderRow={(row) => <span>{row.subject}</span>}
|
|
46
|
+
testId="inbox"
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("InfinityList", () => {
|
|
52
|
+
test("rendert die erste Seite", async () => {
|
|
53
|
+
const dispatcher = createMockDispatcher({
|
|
54
|
+
query: (async () => ({
|
|
55
|
+
isSuccess: true,
|
|
56
|
+
data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
|
|
57
|
+
})) as unknown as Dispatcher["query"],
|
|
58
|
+
});
|
|
59
|
+
renderWithDispatcher(list("inbox:query:message:list"), dispatcher);
|
|
60
|
+
await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("lädt die nächste Seite nach, sobald der Sentinel sichtbar wird", async () => {
|
|
64
|
+
let calls = 0;
|
|
65
|
+
const dispatcher = createMockDispatcher({
|
|
66
|
+
query: (async () => {
|
|
67
|
+
calls += 1;
|
|
68
|
+
if (calls === 1) {
|
|
69
|
+
return {
|
|
70
|
+
isSuccess: true,
|
|
71
|
+
data: { rows: [{ id: "m1", subject: "Erste" }], nextCursor: "c1" },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
isSuccess: true,
|
|
76
|
+
data: { rows: [{ id: "m2", subject: "Zweite" }], nextCursor: null },
|
|
77
|
+
};
|
|
78
|
+
}) as unknown as Dispatcher["query"],
|
|
79
|
+
});
|
|
80
|
+
renderWithDispatcher(list("inbox:query:message:list"), dispatcher);
|
|
81
|
+
await waitFor(() => expect(screen.getByText("Erste")).toBeTruthy());
|
|
82
|
+
fireIntersect();
|
|
83
|
+
await waitFor(() => expect(screen.getByText("Zweite")).toBeTruthy());
|
|
84
|
+
expect(screen.getByText("Erste")).toBeTruthy();
|
|
85
|
+
expect(calls).toBe(2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("Fehler → ErrorState, Retry lädt neu", async () => {
|
|
89
|
+
let calls = 0;
|
|
90
|
+
const dispatcher = createMockDispatcher({
|
|
91
|
+
query: (async () => {
|
|
92
|
+
calls += 1;
|
|
93
|
+
if (calls === 1) {
|
|
94
|
+
return {
|
|
95
|
+
isSuccess: false,
|
|
96
|
+
error: { code: "internal", message: "kaputt", i18nKey: "errors.internal" },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
isSuccess: true,
|
|
101
|
+
data: { rows: [{ id: "m1", subject: "Hallo" }], nextCursor: null },
|
|
102
|
+
};
|
|
103
|
+
}) as unknown as Dispatcher["query"],
|
|
104
|
+
});
|
|
105
|
+
renderWithDispatcher(list("inbox:query:message:list"), dispatcher);
|
|
106
|
+
await waitFor(() => expect(screen.getByRole("button")).toBeTruthy());
|
|
107
|
+
fireEvent.click(screen.getByRole("button"));
|
|
108
|
+
await waitFor(() => expect(screen.getByText("Hallo")).toBeTruthy());
|
|
109
|
+
expect(calls).toBe(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("leeres Result rendert Empty-State", async () => {
|
|
113
|
+
const dispatcher = createMockDispatcher({
|
|
114
|
+
query: (async () => ({
|
|
115
|
+
isSuccess: true,
|
|
116
|
+
data: { rows: [], nextCursor: null },
|
|
117
|
+
})) as unknown as Dispatcher["query"],
|
|
118
|
+
});
|
|
119
|
+
renderWithDispatcher(
|
|
120
|
+
<InfinityList<Page, Row>
|
|
121
|
+
query="inbox:query:message:list"
|
|
122
|
+
rows={(data) => data.rows}
|
|
123
|
+
nextCursor={(data) => data.nextCursor}
|
|
124
|
+
rowId={(row) => row.id}
|
|
125
|
+
renderRow={(row) => <span>{row.subject}</span>}
|
|
126
|
+
emptyState={<span>Keine Nachrichten</span>}
|
|
127
|
+
/>,
|
|
128
|
+
dispatcher,
|
|
129
|
+
);
|
|
130
|
+
await waitFor(() => expect(screen.getByText("Keine Nachrichten")).toBeTruthy());
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -55,6 +55,28 @@ describe("ResultTable", () => {
|
|
|
55
55
|
expect(totalRow?.className).toContain("font-semibold");
|
|
56
56
|
expect(screen.getByText("Subtotal")).toBeTruthy();
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
test("single-column table: footer value still renders (not dropped)", () => {
|
|
60
|
+
const singleColumn = [{ header: "Jahr", cell: (row: { year: number }) => row.year }];
|
|
61
|
+
const { container } = render(
|
|
62
|
+
<ResultTable
|
|
63
|
+
columns={singleColumn}
|
|
64
|
+
rows={ROWS}
|
|
65
|
+
rowKey={(r) => String(r.year)}
|
|
66
|
+
testId="rt"
|
|
67
|
+
footer={[{ label: "Total", value: "300", emphasize: true }]}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
const footerRows = container.querySelectorAll("tbody tr");
|
|
71
|
+
expect(footerRows).toHaveLength(3);
|
|
72
|
+
const totalRow = footerRows[2];
|
|
73
|
+
// Single <td> carries both label and value — colSpan is dropped, not
|
|
74
|
+
// set to 0, since there's only one column to span.
|
|
75
|
+
expect(totalRow?.querySelectorAll("td")).toHaveLength(1);
|
|
76
|
+
expect(totalRow?.querySelector("td")?.hasAttribute("colspan")).toBe(false);
|
|
77
|
+
expect(screen.getByText("Total")).toBeTruthy();
|
|
78
|
+
expect(screen.getByText("300")).toBeTruthy();
|
|
79
|
+
});
|
|
58
80
|
});
|
|
59
81
|
|
|
60
82
|
describe("ComparisonTable", () => {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Sheet,
|
|
4
|
+
SheetContent,
|
|
5
|
+
SheetDescription,
|
|
6
|
+
SheetFooter,
|
|
7
|
+
SheetHeader,
|
|
8
|
+
SheetTitle,
|
|
9
|
+
} from "../ui/sheet";
|
|
10
|
+
|
|
11
|
+
export type DrawerProps = {
|
|
12
|
+
readonly open: boolean;
|
|
13
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
14
|
+
readonly side?: "left" | "right" | "top" | "bottom";
|
|
15
|
+
readonly title?: ReactNode;
|
|
16
|
+
readonly description?: ReactNode;
|
|
17
|
+
readonly footer?: ReactNode;
|
|
18
|
+
readonly children: ReactNode;
|
|
19
|
+
readonly testId?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
|
|
23
|
+
* thin wrapper over the Sheet primitive with header/body/footer slots
|
|
24
|
+
* so screens skip per-route Radix boilerplate. */
|
|
25
|
+
export function Drawer({
|
|
26
|
+
open,
|
|
27
|
+
onOpenChange,
|
|
28
|
+
side = "right",
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
footer,
|
|
32
|
+
children,
|
|
33
|
+
testId,
|
|
34
|
+
}: DrawerProps): ReactNode {
|
|
35
|
+
return (
|
|
36
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
37
|
+
<SheetContent side={side} data-testid={testId}>
|
|
38
|
+
{(title !== undefined || description !== undefined) && (
|
|
39
|
+
<SheetHeader>
|
|
40
|
+
{title !== undefined && <SheetTitle>{title}</SheetTitle>}
|
|
41
|
+
{description !== undefined && <SheetDescription>{description}</SheetDescription>}
|
|
42
|
+
</SheetHeader>
|
|
43
|
+
)}
|
|
44
|
+
<div className="flex-1 overflow-y-auto px-4">{children}</div>
|
|
45
|
+
{footer !== undefined && <SheetFooter>{footer}</SheetFooter>}
|
|
46
|
+
</SheetContent>
|
|
47
|
+
</Sheet>
|
|
48
|
+
);
|
|
49
|
+
}
|
package/src/widgets/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export {
|
|
|
18
18
|
} from "./charts";
|
|
19
19
|
export { CollapsibleSection } from "./collapsible-section";
|
|
20
20
|
export { DetailList } from "./detail-list";
|
|
21
|
+
export { Drawer, type DrawerProps } from "./drawer";
|
|
21
22
|
export { FeedList, type FeedRow } from "./feed-list";
|
|
22
23
|
export {
|
|
23
24
|
BooleanField,
|
|
@@ -39,6 +40,7 @@ export {
|
|
|
39
40
|
TextField,
|
|
40
41
|
type TextFieldProps,
|
|
41
42
|
} from "./form-fields";
|
|
43
|
+
export { InfinityList, type InfinityListProps } from "./infinity-list";
|
|
42
44
|
export { ModeSwitch } from "./mode-switch";
|
|
43
45
|
export { ProgressBar } from "./progress-bar";
|
|
44
46
|
export { ProgressList, type ProgressListRow } from "./progress-list";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { EmptyState, ErrorState, LoadingState } from "./states";
|
|
5
|
+
|
|
6
|
+
export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, unknown>>> = {
|
|
7
|
+
/** Dispatcher-Query-Type (`<feature>:query:<entity>:<verb>`). */
|
|
8
|
+
readonly query: string;
|
|
9
|
+
readonly payload?: Readonly<Record<string, unknown>>;
|
|
10
|
+
/** Rows per page — sent as `limit` in the query payload. Default 50. */
|
|
11
|
+
readonly pageSize?: number;
|
|
12
|
+
readonly rows: (data: TData) => readonly TRow[];
|
|
13
|
+
/** Pull the next-page cursor from the result; `null` means last page. */
|
|
14
|
+
readonly nextCursor: (data: TData) => string | null;
|
|
15
|
+
readonly rowId: (row: TRow, index: number) => string;
|
|
16
|
+
readonly renderRow: (row: TRow) => ReactNode;
|
|
17
|
+
readonly emptyState?: ReactNode;
|
|
18
|
+
readonly className?: string;
|
|
19
|
+
readonly testId?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type State<TRow> =
|
|
23
|
+
| { readonly kind: "loading" }
|
|
24
|
+
| { readonly kind: "error"; readonly error: DispatcherError }
|
|
25
|
+
| { readonly kind: "ready"; readonly rows: readonly TRow[]; readonly cursor: string | null };
|
|
26
|
+
|
|
27
|
+
/** Cursor-paginated scroll list (mail inbox, activity feeds) — loads the
|
|
28
|
+
* next page when the end sentinel becomes visible (IntersectionObserver),
|
|
29
|
+
* instead of a pager bar like QueryTable/entityList. The query handler
|
|
30
|
+
* receives `{ ...payload, limit, cursor? }` and returns rows plus the next
|
|
31
|
+
* cursor (same cursor convention as the audit-log screen). */
|
|
32
|
+
export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unknown>>>({
|
|
33
|
+
query,
|
|
34
|
+
payload,
|
|
35
|
+
pageSize = 50,
|
|
36
|
+
rows,
|
|
37
|
+
nextCursor,
|
|
38
|
+
rowId,
|
|
39
|
+
renderRow,
|
|
40
|
+
emptyState,
|
|
41
|
+
className,
|
|
42
|
+
testId,
|
|
43
|
+
}: InfinityListProps<TData, TRow>): ReactNode {
|
|
44
|
+
const dispatcher = useDispatcher();
|
|
45
|
+
const t = useTranslation();
|
|
46
|
+
const [state, setState] = useState<State<TRow>>({ kind: "loading" });
|
|
47
|
+
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
48
|
+
const payloadKey = JSON.stringify(payload ?? {});
|
|
49
|
+
|
|
50
|
+
// rows/nextCursor are fresh closures on every caller render (inline
|
|
51
|
+
// arrow props). As useCallback deps that would recreate `load` every
|
|
52
|
+
// render → the mount effect below would refetch in a loop. Refs keep
|
|
53
|
+
// `load` stable while always reading the current selector.
|
|
54
|
+
const rowsRef = useRef(rows);
|
|
55
|
+
rowsRef.current = rows;
|
|
56
|
+
const nextCursorRef = useRef(nextCursor);
|
|
57
|
+
nextCursorRef.current = nextCursor;
|
|
58
|
+
|
|
59
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
|
|
60
|
+
const load = useCallback(
|
|
61
|
+
async (cursor: string | null): Promise<void> => {
|
|
62
|
+
const res = await dispatcher.query<TData>(query, {
|
|
63
|
+
...payload,
|
|
64
|
+
limit: pageSize,
|
|
65
|
+
...(cursor !== null && { cursor }),
|
|
66
|
+
});
|
|
67
|
+
if (!res.isSuccess) {
|
|
68
|
+
setState({ kind: "error", error: res.error });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const nextRows = rowsRef.current(res.data);
|
|
72
|
+
setState((prev) => ({
|
|
73
|
+
kind: "ready",
|
|
74
|
+
rows: cursor === null || prev.kind !== "ready" ? nextRows : [...prev.rows, ...nextRows],
|
|
75
|
+
cursor: nextCursorRef.current(res.data),
|
|
76
|
+
}));
|
|
77
|
+
},
|
|
78
|
+
[dispatcher, query, pageSize, payloadKey],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setState({ kind: "loading" });
|
|
83
|
+
void load(null);
|
|
84
|
+
}, [load]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const sentinel = sentinelRef.current;
|
|
88
|
+
// skip: no further page or not ready yet — observer not needed
|
|
89
|
+
if (sentinel === null || state.kind !== "ready" || state.cursor === null) return;
|
|
90
|
+
const cursor = state.cursor;
|
|
91
|
+
const observer = new IntersectionObserver((entries) => {
|
|
92
|
+
if (entries[0]?.isIntersecting === true) void load(cursor);
|
|
93
|
+
});
|
|
94
|
+
observer.observe(sentinel);
|
|
95
|
+
return () => observer.disconnect();
|
|
96
|
+
}, [state, load]);
|
|
97
|
+
|
|
98
|
+
if (state.kind === "loading") return <LoadingState rows={4} testId={testId} />;
|
|
99
|
+
if (state.kind === "error")
|
|
100
|
+
return <ErrorState error={state.error} onRetry={() => void load(null)} testId={testId} />;
|
|
101
|
+
if (state.rows.length === 0)
|
|
102
|
+
return <>{emptyState ?? <EmptyState title={t("kumiko.list.no-entries")} testId={testId} />}</>;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div data-testid={testId} className={className ?? "flex flex-col overflow-y-auto"}>
|
|
106
|
+
{state.rows.map((row, index) => (
|
|
107
|
+
<div key={rowId(row, index)}>{renderRow(row)}</div>
|
|
108
|
+
))}
|
|
109
|
+
<div ref={sentinelRef} className="h-px" />
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -129,14 +129,25 @@ export function ResultTable<Row>({
|
|
|
129
129
|
className={cn(i === 0 && "border-t", row.emphasize === true && "font-semibold")}
|
|
130
130
|
>
|
|
131
131
|
<td
|
|
132
|
-
colSpan={Math.max(columns.length - 1, 1)}
|
|
132
|
+
colSpan={columns.length > 1 ? Math.max(columns.length - 1, 1) : undefined}
|
|
133
133
|
className={cn(
|
|
134
134
|
"py-1.5 text-muted-foreground",
|
|
135
135
|
card === true && "px-3",
|
|
136
136
|
row.emphasize === true && "text-foreground",
|
|
137
|
+
columns.length === 1 && "flex items-center justify-between gap-4",
|
|
137
138
|
)}
|
|
138
139
|
>
|
|
139
140
|
{row.label}
|
|
141
|
+
{columns.length === 1 && (
|
|
142
|
+
<span
|
|
143
|
+
className={cn(
|
|
144
|
+
"text-right tabular-nums",
|
|
145
|
+
row.emphasize === true && "text-foreground",
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{row.value}
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
140
151
|
</td>
|
|
141
152
|
{columns.length > 1 && (
|
|
142
153
|
<td className={cn("py-1.5 text-right tabular-nums", card === true && "px-3")}>
|