@cosmicdrift/kumiko-renderer-web 0.1.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 +63 -0
- package/src/__tests__/avatar.test.tsx +34 -0
- package/src/__tests__/combobox.test.tsx +240 -0
- package/src/__tests__/config-edit.test.tsx +172 -0
- package/src/__tests__/create-app.test.tsx +261 -0
- package/src/__tests__/date-input.test.tsx +91 -0
- package/src/__tests__/default-app-shell.test.tsx +60 -0
- package/src/__tests__/dispatcher-context.test.tsx +101 -0
- package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
- package/src/__tests__/kumiko-screen.test.tsx +1014 -0
- package/src/__tests__/language-switcher.test.tsx +100 -0
- package/src/__tests__/money-input.test.tsx +232 -0
- package/src/__tests__/nav-base-path.test.tsx +388 -0
- package/src/__tests__/nav-search-params.test.tsx +88 -0
- package/src/__tests__/nav-tree.test.tsx +183 -0
- package/src/__tests__/nav.test.tsx +253 -0
- package/src/__tests__/primitives.test.tsx +936 -0
- package/src/__tests__/render-edit.test.tsx +178 -0
- package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
- package/src/__tests__/render-list-debounce.test.tsx +128 -0
- package/src/__tests__/render-list.test.tsx +151 -0
- package/src/__tests__/sidebar.test.tsx +59 -0
- package/src/__tests__/test-utils.tsx +144 -0
- package/src/__tests__/theme-toggle.test.tsx +101 -0
- package/src/__tests__/toast.test.tsx +162 -0
- package/src/__tests__/use-form.test.tsx +112 -0
- package/src/__tests__/use-query-live.test.tsx +152 -0
- package/src/__tests__/use-query.test.tsx +88 -0
- package/src/__tests__/use-store.test.tsx +139 -0
- package/src/__tests__/workspace-shell.test.tsx +772 -0
- package/src/app/browser-locale.ts +85 -0
- package/src/app/client-plugin.tsx +63 -0
- package/src/app/create-app.tsx +380 -0
- package/src/app/nav.tsx +226 -0
- package/src/index.ts +137 -0
- package/src/layout/app-layout.tsx +35 -0
- package/src/layout/avatar.tsx +93 -0
- package/src/layout/default-app-shell.tsx +74 -0
- package/src/layout/language-switcher.tsx +101 -0
- package/src/layout/nav-tree.tsx +281 -0
- package/src/layout/profile-menu.tsx +40 -0
- package/src/layout/sidebar.tsx +65 -0
- package/src/layout/theme-toggle.tsx +44 -0
- package/src/layout/topbar.tsx +22 -0
- package/src/layout/workspace-shell.tsx +282 -0
- package/src/layout/workspace-switcher.tsx +62 -0
- package/src/lib/cn.ts +10 -0
- package/src/primitives/action-menu.tsx +111 -0
- package/src/primitives/combobox.tsx +261 -0
- package/src/primitives/date-input.tsx +165 -0
- package/src/primitives/dialog.tsx +119 -0
- package/src/primitives/dropdown-menu.tsx +103 -0
- package/src/primitives/index.tsx +1271 -0
- package/src/primitives/money-input.tsx +192 -0
- package/src/primitives/toast.tsx +166 -0
- package/src/sse/live-events.ts +90 -0
- package/src/styles.css +113 -0
- package/src/tokens.ts +63 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { formatPath, NavProvider, parsePath, useNav } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
6
|
+
import { KumikoLink, useBrowserNavApi } from "../app/nav";
|
|
7
|
+
|
|
8
|
+
describe("parsePath", () => {
|
|
9
|
+
test("leerer Pfad / → undefined", () => {
|
|
10
|
+
expect(parsePath("/")).toBeUndefined();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("/<screenId> → { screenId }", () => {
|
|
14
|
+
expect(parsePath("/task-list")).toEqual({ screenId: "task-list" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("/<screenId>/<entityId> → { screenId, entityId }", () => {
|
|
18
|
+
expect(parsePath("/task-edit/abc-123")).toEqual({
|
|
19
|
+
screenId: "task-edit",
|
|
20
|
+
entityId: "abc-123",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("trailing-slash wird toleriert", () => {
|
|
25
|
+
expect(parsePath("/task-list/")).toEqual({ screenId: "task-list" });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("zusätzliche segmente werden ignoriert (kein nested routing)", () => {
|
|
29
|
+
expect(parsePath("/task-edit/abc-123/extra/segments")).toEqual({
|
|
30
|
+
screenId: "task-edit",
|
|
31
|
+
entityId: "abc-123",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("formatPath", () => {
|
|
37
|
+
test("nur screenId", () => {
|
|
38
|
+
expect(formatPath({ screenId: "task-list" })).toBe("/task-list");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("screenId + entityId", () => {
|
|
42
|
+
expect(formatPath({ screenId: "task-edit", entityId: "abc-123" })).toBe("/task-edit/abc-123");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("workspaceId prefix bei Workspace-Mode", () => {
|
|
46
|
+
expect(formatPath({ workspaceId: "admin", screenId: "task-list" })).toBe("/admin/task-list");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("workspaceId + screenId + entityId", () => {
|
|
50
|
+
expect(
|
|
51
|
+
formatPath({ workspaceId: "dispatch", screenId: "order-edit", entityId: "abc-123" }),
|
|
52
|
+
).toBe("/dispatch/order-edit/abc-123");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("parsePath — workspace mode", () => {
|
|
57
|
+
test("/<workspaceId>/<screenId> → trägt beide", () => {
|
|
58
|
+
expect(parsePath("/admin/task-list", true)).toEqual({
|
|
59
|
+
workspaceId: "admin",
|
|
60
|
+
screenId: "task-list",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("/<workspaceId>/<screenId>/<entityId> → mit entityId", () => {
|
|
65
|
+
expect(parsePath("/admin/task-edit/abc-123", true)).toEqual({
|
|
66
|
+
workspaceId: "admin",
|
|
67
|
+
screenId: "task-edit",
|
|
68
|
+
entityId: "abc-123",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("/<workspaceId> ohne screen → screenId leer (caller resolved Default)", () => {
|
|
73
|
+
expect(parsePath("/admin", true)).toEqual({ workspaceId: "admin", screenId: "" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("/ → undefined auch im Workspace-Mode", () => {
|
|
77
|
+
expect(parsePath("/", true)).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Wrapper der das web-spezifische useBrowserNavApi aufruft und in
|
|
82
|
+
// einen shared-NavProvider durchreicht. Das ist genau das was
|
|
83
|
+
// createKumikoApp intern macht.
|
|
84
|
+
function BrowserNav({ children }: { readonly children: ReactNode }): ReactNode {
|
|
85
|
+
const api = useBrowserNavApi();
|
|
86
|
+
return <NavProvider value={api}>{children}</NavProvider>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("useBrowserNavApi + NavProvider", () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
// jsdom teilt window.history zwischen Tests — auf / zurücksetzen,
|
|
92
|
+
// sonst leaken Routen aus vorigen Tests in die nächsten.
|
|
93
|
+
window.history.replaceState(null, "", "/");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function Probe(): React.ReactElement {
|
|
97
|
+
const nav = useNav();
|
|
98
|
+
return (
|
|
99
|
+
<div>
|
|
100
|
+
<span data-testid="screen-id">{nav.route?.screenId ?? "(none)"}</span>
|
|
101
|
+
<span data-testid="entity-id">{nav.route?.entityId ?? "(none)"}</span>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
data-testid="go-list"
|
|
105
|
+
onClick={() => nav.navigate({ screenId: "task-list" })}
|
|
106
|
+
>
|
|
107
|
+
go-list
|
|
108
|
+
</button>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
data-testid="go-edit"
|
|
112
|
+
onClick={() => nav.navigate({ screenId: "task-edit", entityId: "xyz" })}
|
|
113
|
+
>
|
|
114
|
+
go-edit
|
|
115
|
+
</button>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
data-testid="replace-list"
|
|
119
|
+
onClick={() => nav.replace({ screenId: "task-list" })}
|
|
120
|
+
>
|
|
121
|
+
replace-list
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test("initial-route aus window.location.pathname", () => {
|
|
128
|
+
window.history.replaceState(null, "", "/task-list");
|
|
129
|
+
render(
|
|
130
|
+
<BrowserNav>
|
|
131
|
+
<Probe />
|
|
132
|
+
</BrowserNav>,
|
|
133
|
+
);
|
|
134
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
135
|
+
expect(screen.getByTestId("entity-id").textContent).toBe("(none)");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("navigate() aktualisiert location + re-rendert", () => {
|
|
139
|
+
render(
|
|
140
|
+
<BrowserNav>
|
|
141
|
+
<Probe />
|
|
142
|
+
</BrowserNav>,
|
|
143
|
+
);
|
|
144
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("(none)");
|
|
145
|
+
|
|
146
|
+
act(() => {
|
|
147
|
+
fireEvent.click(screen.getByTestId("go-edit"));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(window.location.pathname).toBe("/task-edit/xyz");
|
|
151
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-edit");
|
|
152
|
+
expect(screen.getByTestId("entity-id").textContent).toBe("xyz");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("replace() aktualisiert location ohne History-Eintrag", () => {
|
|
156
|
+
render(
|
|
157
|
+
<BrowserNav>
|
|
158
|
+
<Probe />
|
|
159
|
+
</BrowserNav>,
|
|
160
|
+
);
|
|
161
|
+
const before = window.history.length;
|
|
162
|
+
act(() => {
|
|
163
|
+
fireEvent.click(screen.getByTestId("replace-list"));
|
|
164
|
+
});
|
|
165
|
+
expect(window.location.pathname).toBe("/task-list");
|
|
166
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
167
|
+
// Das ist der Unterschied zu navigate(): keine zusätzliche History-
|
|
168
|
+
// Stufe. Browser-Back springt damit zur Origin-Seite zurück, nicht
|
|
169
|
+
// auf die alte URL — wichtig für Mount-Time URL-Fills wie in
|
|
170
|
+
// WorkspaceShell, wo der User die alte URL nie gewählt hat.
|
|
171
|
+
expect(window.history.length).toBe(before);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("popstate (Browser-Back) re-rendert die aktuelle Route", () => {
|
|
175
|
+
render(
|
|
176
|
+
<BrowserNav>
|
|
177
|
+
<Probe />
|
|
178
|
+
</BrowserNav>,
|
|
179
|
+
);
|
|
180
|
+
act(() => {
|
|
181
|
+
fireEvent.click(screen.getByTestId("go-list"));
|
|
182
|
+
});
|
|
183
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("task-list");
|
|
184
|
+
|
|
185
|
+
// Simulate Back-Button: history.replaceState statt back() — jsdom's
|
|
186
|
+
// back() feuert nicht immer synchron popstate. Wir dispatchen das
|
|
187
|
+
// Event manuell, genau wie es der Browser tun würde.
|
|
188
|
+
act(() => {
|
|
189
|
+
window.history.replaceState(null, "", "/");
|
|
190
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
191
|
+
});
|
|
192
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("(none)");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("KumikoLink", () => {
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
window.history.replaceState(null, "", "/");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("rendert <a> mit korrekter href", () => {
|
|
202
|
+
render(
|
|
203
|
+
<BrowserNav>
|
|
204
|
+
<KumikoLink to={{ screenId: "task-edit", entityId: "xyz" }}>Edit</KumikoLink>
|
|
205
|
+
</BrowserNav>,
|
|
206
|
+
);
|
|
207
|
+
const anchor = screen.getByText("Edit") as HTMLAnchorElement;
|
|
208
|
+
expect(anchor.tagName).toBe("A");
|
|
209
|
+
expect(anchor.getAttribute("href")).toBe("/task-edit/xyz");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("left-click wird abgefangen → navigate() statt full reload", () => {
|
|
213
|
+
render(
|
|
214
|
+
<BrowserNav>
|
|
215
|
+
<KumikoLink to={{ screenId: "task-list" }}>Liste</KumikoLink>
|
|
216
|
+
</BrowserNav>,
|
|
217
|
+
);
|
|
218
|
+
act(() => {
|
|
219
|
+
fireEvent.click(screen.getByText("Liste"), { button: 0 });
|
|
220
|
+
});
|
|
221
|
+
expect(window.location.pathname).toBe("/task-list");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("meta-click (Cmd/Ctrl) wird NICHT abgefangen — Browser öffnet in neuem Tab", () => {
|
|
225
|
+
render(
|
|
226
|
+
<BrowserNav>
|
|
227
|
+
<KumikoLink to={{ screenId: "task-list" }}>Liste</KumikoLink>
|
|
228
|
+
</BrowserNav>,
|
|
229
|
+
);
|
|
230
|
+
const anchor = screen.getByText("Liste") as HTMLAnchorElement;
|
|
231
|
+
let kumikoLinkPreventedDefault: boolean | undefined;
|
|
232
|
+
const observer = (e: Event) => {
|
|
233
|
+
kumikoLinkPreventedDefault = e.defaultPrevented;
|
|
234
|
+
e.preventDefault(); // silence jsdom nav
|
|
235
|
+
};
|
|
236
|
+
anchor.addEventListener("click", observer);
|
|
237
|
+
try {
|
|
238
|
+
act(() => {
|
|
239
|
+
anchor.dispatchEvent(
|
|
240
|
+
new MouseEvent("click", {
|
|
241
|
+
bubbles: true,
|
|
242
|
+
cancelable: true,
|
|
243
|
+
button: 0,
|
|
244
|
+
metaKey: true,
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
} finally {
|
|
249
|
+
anchor.removeEventListener("click", observer);
|
|
250
|
+
}
|
|
251
|
+
expect(kumikoLinkPreventedDefault).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
});
|