@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
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "BUSL-1.1",
|
|
6
|
+
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"kumiko": {
|
|
9
|
+
"runtime": "client"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.ts",
|
|
13
|
+
"./styles.css": "./src/styles.css"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@cosmicdrift/kumiko-dispatcher-live": "workspace:*",
|
|
17
|
+
"@cosmicdrift/kumiko-headless": "workspace:*",
|
|
18
|
+
"@cosmicdrift/kumiko-renderer": "workspace:*",
|
|
19
|
+
"@radix-ui/react-dialog": "^1.1.6",
|
|
20
|
+
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
|
21
|
+
"@radix-ui/react-label": "^2.1.2",
|
|
22
|
+
"@radix-ui/react-popover": "^1.1.6",
|
|
23
|
+
"@radix-ui/react-select": "^2.1.6",
|
|
24
|
+
"@radix-ui/react-slot": "^1.1.2",
|
|
25
|
+
"@radix-ui/react-toast": "^1.2.6",
|
|
26
|
+
"@radix-ui/react-tooltip": "^1.1.8",
|
|
27
|
+
"class-variance-authority": "^0.7.1",
|
|
28
|
+
"clsx": "^2.1.1",
|
|
29
|
+
"cmdk": "^1.1.1",
|
|
30
|
+
"lucide-react": "^1.11.0",
|
|
31
|
+
"react": "^19.2.0",
|
|
32
|
+
"react-day-picker": "^9.14.0",
|
|
33
|
+
"react-dom": "^19.2.0",
|
|
34
|
+
"tailwind-merge": "^3.0.2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@tailwindcss/cli": "^4.0.0",
|
|
38
|
+
"@testing-library/react": "^16.3.0",
|
|
39
|
+
"@testing-library/user-event": "^14.6.1",
|
|
40
|
+
"@types/react": "^19.2.0",
|
|
41
|
+
"@types/react-dom": "^19.2.0",
|
|
42
|
+
"jsdom": "^29.1.0",
|
|
43
|
+
"tailwindcss": "^4.0.0"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/cosmicdriftgamestudio/kumiko-framework.git",
|
|
48
|
+
"directory": "packages/renderer-web"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://kumiko.so",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/cosmicdriftgamestudio/kumiko-framework/issues"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"registry": "https://registry.npmjs.org",
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"files": [
|
|
59
|
+
"src",
|
|
60
|
+
"README.md",
|
|
61
|
+
"LICENSE"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { Avatar } from "../layout/avatar";
|
|
4
|
+
import { render, screen } from "./test-utils";
|
|
5
|
+
|
|
6
|
+
describe("Avatar", () => {
|
|
7
|
+
test("Initials aus 'Daniel Hennig' → 'DH'", () => {
|
|
8
|
+
render(<Avatar id="user-1" label="Daniel Hennig" />);
|
|
9
|
+
expect(screen.getByRole("img", { name: "Daniel Hennig" }).textContent).toBe("DH");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("Initials aus single-name 'Daniel' → 'DA' (erste 2 Buchstaben)", () => {
|
|
13
|
+
render(<Avatar id="user-2" label="Daniel" />);
|
|
14
|
+
expect(screen.getByRole("img", { name: "Daniel" }).textContent).toBe("DA");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("Initials aus email 'alice@example.com' → 'AL' (erste 2 Buchstaben)", () => {
|
|
18
|
+
render(<Avatar id="user-3" label="alice@example.com" />);
|
|
19
|
+
expect(screen.getByRole("img", { name: "alice@example.com" }).textContent).toBe("AL");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("empty label → '?'-Fallback", () => {
|
|
23
|
+
render(<Avatar id="user-4" label="" />);
|
|
24
|
+
expect(screen.getByRole("img", { name: "" }).textContent).toBe("?");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("gleiche id → gleiche Color-Klasse (deterministic)", () => {
|
|
28
|
+
const { rerender, container } = render(<Avatar id="stable-id" label="A B" />);
|
|
29
|
+
const colorBefore = container.querySelector("[role='img']")?.className;
|
|
30
|
+
rerender(<Avatar id="stable-id" label="C D" />);
|
|
31
|
+
const colorAfter = container.querySelector("[role='img']")?.className;
|
|
32
|
+
expect(colorBefore).toBe(colorAfter);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { fireEvent } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import { ComboboxInput } from "../primitives/combobox";
|
|
7
|
+
import { render, screen } from "./test-utils";
|
|
8
|
+
|
|
9
|
+
// Tier 2.1c: Combobox-Primitive Smoke-Tests. cmdk + Radix-Popover
|
|
10
|
+
// rendern Portals; jsdom resolved sie auf document.body. Wir testen
|
|
11
|
+
// die Trigger-Render-Form (Single + Multi mit Tags) und den Click→
|
|
12
|
+
// Select Roundtrip. Volltext-Filter-Mechanik ist cmdk-internal und
|
|
13
|
+
// dort getestet — unsere Tests pinnen nur das Wiring.
|
|
14
|
+
|
|
15
|
+
describe("ComboboxInput (Tier 2.1c)", () => {
|
|
16
|
+
test("single-mode: Trigger zeigt placeholder wenn value leer", () => {
|
|
17
|
+
render(
|
|
18
|
+
<ComboboxInput
|
|
19
|
+
id="combo"
|
|
20
|
+
name="combo"
|
|
21
|
+
value=""
|
|
22
|
+
onChange={() => {}}
|
|
23
|
+
options={[
|
|
24
|
+
{ value: "a", label: "Alpha" },
|
|
25
|
+
{ value: "b", label: "Beta" },
|
|
26
|
+
]}
|
|
27
|
+
placeholder="Pick one"
|
|
28
|
+
/>,
|
|
29
|
+
);
|
|
30
|
+
expect(screen.getByText("Pick one")).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("single-mode: Trigger zeigt Label des selected value", () => {
|
|
34
|
+
render(
|
|
35
|
+
<ComboboxInput
|
|
36
|
+
id="combo"
|
|
37
|
+
name="combo"
|
|
38
|
+
value="b"
|
|
39
|
+
onChange={() => {}}
|
|
40
|
+
options={[
|
|
41
|
+
{ value: "a", label: "Alpha" },
|
|
42
|
+
{ value: "b", label: "Beta" },
|
|
43
|
+
]}
|
|
44
|
+
/>,
|
|
45
|
+
);
|
|
46
|
+
expect(screen.getByText("Beta")).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("single-mode: Click auf Item → onChange mit value, Popover schließt", async () => {
|
|
50
|
+
const user = userEvent.setup();
|
|
51
|
+
const changes: string[] = [];
|
|
52
|
+
render(
|
|
53
|
+
<ComboboxInput
|
|
54
|
+
id="combo"
|
|
55
|
+
name="combo"
|
|
56
|
+
value=""
|
|
57
|
+
onChange={(v) => changes.push(v as string)}
|
|
58
|
+
options={[
|
|
59
|
+
{ value: "a", label: "Alpha" },
|
|
60
|
+
{ value: "b", label: "Beta" },
|
|
61
|
+
]}
|
|
62
|
+
/>,
|
|
63
|
+
);
|
|
64
|
+
// Trigger klicken → Popover öffnet, Items rendern
|
|
65
|
+
await user.click(screen.getByTestId("combobox-combo"));
|
|
66
|
+
// cmdk Items haben das role="option" attr
|
|
67
|
+
const beta = await screen.findByText("Beta");
|
|
68
|
+
await user.click(beta);
|
|
69
|
+
expect(changes).toEqual(["b"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("multi-mode: leerer state zeigt placeholder", () => {
|
|
73
|
+
render(
|
|
74
|
+
<ComboboxInput
|
|
75
|
+
id="combo"
|
|
76
|
+
name="combo"
|
|
77
|
+
value={[]}
|
|
78
|
+
onChange={() => {}}
|
|
79
|
+
options={[{ value: "a", label: "Alpha" }]}
|
|
80
|
+
multiple
|
|
81
|
+
placeholder="Pick tags"
|
|
82
|
+
/>,
|
|
83
|
+
);
|
|
84
|
+
expect(screen.getByText("Pick tags")).toBeTruthy();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("multi-mode: vorhandene values rendern als Tags mit Label", () => {
|
|
88
|
+
render(
|
|
89
|
+
<ComboboxInput
|
|
90
|
+
id="combo"
|
|
91
|
+
name="combo"
|
|
92
|
+
value={["a", "b"]}
|
|
93
|
+
onChange={() => {}}
|
|
94
|
+
options={[
|
|
95
|
+
{ value: "a", label: "Alpha" },
|
|
96
|
+
{ value: "b", label: "Beta" },
|
|
97
|
+
{ value: "c", label: "Gamma" },
|
|
98
|
+
]}
|
|
99
|
+
multiple
|
|
100
|
+
/>,
|
|
101
|
+
);
|
|
102
|
+
expect(screen.getByText("Alpha")).toBeTruthy();
|
|
103
|
+
expect(screen.getByText("Beta")).toBeTruthy();
|
|
104
|
+
// Gamma ist nicht selected → kein Tag im Trigger.
|
|
105
|
+
expect(screen.queryByText("Gamma")).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("multi-mode: Click auf neues Item → onChange mit erweitertem Array", async () => {
|
|
109
|
+
const user = userEvent.setup();
|
|
110
|
+
const changes: (readonly string[])[] = [];
|
|
111
|
+
render(
|
|
112
|
+
<ComboboxInput
|
|
113
|
+
id="combo"
|
|
114
|
+
name="combo"
|
|
115
|
+
value={["a"]}
|
|
116
|
+
onChange={(v) => changes.push(v as readonly string[])}
|
|
117
|
+
options={[
|
|
118
|
+
{ value: "a", label: "Alpha" },
|
|
119
|
+
{ value: "b", label: "Beta" },
|
|
120
|
+
]}
|
|
121
|
+
multiple
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
await user.click(screen.getByTestId("combobox-combo"));
|
|
125
|
+
// In Multi-Mode mountet cmdk die Items im Portal — wir suchen
|
|
126
|
+
// das Item per Text. Beim Klick toggled der Combobox die value.
|
|
127
|
+
const items = await screen.findAllByText("Beta");
|
|
128
|
+
// Erstes "Beta" könnte das selected-Tag oben sein; in der List
|
|
129
|
+
// rendert cmdk eine zweite Instanz. Tag ist nicht da (a war
|
|
130
|
+
// selected, b nicht), also ist nur das List-Item da.
|
|
131
|
+
await user.click(items[items.length - 1] as HTMLElement);
|
|
132
|
+
expect(changes).toHaveLength(1);
|
|
133
|
+
expect([...(changes[0] ?? [])].sort()).toEqual(["a", "b"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("disabled: Trigger ist disabled", () => {
|
|
137
|
+
render(
|
|
138
|
+
<ComboboxInput id="combo" name="combo" value="" onChange={() => {}} options={[]} disabled />,
|
|
139
|
+
);
|
|
140
|
+
expect((screen.getByTestId("combobox-combo") as HTMLButtonElement).disabled).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Regression aus PublicStatus-Live-Debug: Multi-Mode + Remote-Search
|
|
144
|
+
// (= Reference-Field mit `multiple: true`) — Click auf Item hat im
|
|
145
|
+
// Browser nur Focus gesetzt, nicht onChange ausgelöst. Non-Remote
|
|
146
|
+
// Multi-Mode (Test oben) funktionierte, jeder andere Pfad auch — nur
|
|
147
|
+
// diese Kombination war kaputt.
|
|
148
|
+
test("multi-mode + remote-mode: Click auf Item → onChange mit erweitertem Array", async () => {
|
|
149
|
+
const user = userEvent.setup();
|
|
150
|
+
const changes: (readonly string[])[] = [];
|
|
151
|
+
render(
|
|
152
|
+
<ComboboxInput
|
|
153
|
+
id="combo"
|
|
154
|
+
name="combo"
|
|
155
|
+
value={[]}
|
|
156
|
+
onChange={(v) => changes.push(v as readonly string[])}
|
|
157
|
+
options={[
|
|
158
|
+
{ value: "a", label: "Alpha" },
|
|
159
|
+
{ value: "b", label: "Beta" },
|
|
160
|
+
]}
|
|
161
|
+
multiple
|
|
162
|
+
// Remote-Mode: onSearchChange-Prop signalisiert "server-side
|
|
163
|
+
// filter", also das Pattern aus Reference-Inputs in Forms.
|
|
164
|
+
onSearchChange={() => {}}
|
|
165
|
+
/>,
|
|
166
|
+
);
|
|
167
|
+
await user.click(screen.getByTestId("combobox-combo"));
|
|
168
|
+
const items = await screen.findAllByText("Beta");
|
|
169
|
+
await user.click(items[items.length - 1] as HTMLElement);
|
|
170
|
+
expect(changes).toHaveLength(1);
|
|
171
|
+
expect(changes[0]).toEqual(["b"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Tier 2.7e Remote-Mode: typed-search-API.
|
|
175
|
+
test("remote-mode: render mit onSearchChange + loading mountet ohne crash", () => {
|
|
176
|
+
render(
|
|
177
|
+
<ComboboxInput
|
|
178
|
+
id="combo"
|
|
179
|
+
name="combo"
|
|
180
|
+
value=""
|
|
181
|
+
onChange={() => {}}
|
|
182
|
+
options={[{ value: "a", label: "Alpha" }]}
|
|
183
|
+
onSearchChange={() => {}}
|
|
184
|
+
loading
|
|
185
|
+
/>,
|
|
186
|
+
);
|
|
187
|
+
expect(screen.getByTestId("combobox-combo")).toBeTruthy();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Audit-Fix #3: Real-Search-Verhalten ohne fake-Timers (collidieren
|
|
191
|
+
// mit RTL findBy-polling). defaultOpen forciert den Popover-Mount,
|
|
192
|
+
// dann triggern wir change-event direkt am Search-Input und warten
|
|
193
|
+
// auf real-time 350ms damit der 300ms-Debounce durch ist.
|
|
194
|
+
test("remote-mode: Search-Input typing → onSearchChange debounced (300ms)", async () => {
|
|
195
|
+
const searches: string[] = [];
|
|
196
|
+
render(
|
|
197
|
+
<ComboboxInput
|
|
198
|
+
id="combo"
|
|
199
|
+
name="combo"
|
|
200
|
+
value=""
|
|
201
|
+
onChange={() => {}}
|
|
202
|
+
options={[{ value: "a", label: "Alpha" }]}
|
|
203
|
+
onSearchChange={(q) => searches.push(q)}
|
|
204
|
+
defaultOpen
|
|
205
|
+
/>,
|
|
206
|
+
);
|
|
207
|
+
const searchInput = await screen.findByRole("combobox");
|
|
208
|
+
await act(async () => {
|
|
209
|
+
fireEvent.change(searchInput, { target: { value: "abc" } });
|
|
210
|
+
});
|
|
211
|
+
// Real-time Debounce-Window — kein fake-timers weil das mit
|
|
212
|
+
// findByRole's polling kollidiert.
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
214
|
+
expect(searches[searches.length - 1]).toBe("abc");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("remote-mode: loading=true zeigt 'Loading…' im Empty-State", async () => {
|
|
218
|
+
render(
|
|
219
|
+
<ComboboxInput
|
|
220
|
+
id="combo"
|
|
221
|
+
name="combo"
|
|
222
|
+
value=""
|
|
223
|
+
onChange={() => {}}
|
|
224
|
+
options={[]}
|
|
225
|
+
onSearchChange={() => {}}
|
|
226
|
+
loading
|
|
227
|
+
defaultOpen
|
|
228
|
+
/>,
|
|
229
|
+
);
|
|
230
|
+
const loadingText = await screen.findByText("Loading…");
|
|
231
|
+
expect(loadingText).toBeTruthy();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("hasError: Trigger hat aria-invalid=true", () => {
|
|
235
|
+
render(
|
|
236
|
+
<ComboboxInput id="combo" name="combo" value="" onChange={() => {}} options={[]} hasError />,
|
|
237
|
+
);
|
|
238
|
+
expect(screen.getByTestId("combobox-combo").getAttribute("aria-invalid")).toBe("true");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// Unit-Tests für den configEdit-Screen-Type. Decken die Pfade ab die
|
|
4
|
+
// Integration + E2E nur indirekt sehen:
|
|
5
|
+
// - Initial-Load via config:query:values + Pre-Fill aus values[qn]
|
|
6
|
+
// - customSubmit dispatcht pro geändertem Field einen separaten
|
|
7
|
+
// config:write:set Call mit dem qualifizierten Key + scope
|
|
8
|
+
// - Save-Button Greying via controller.rebase nach Success
|
|
9
|
+
// - Loading-State während config:query:values noch läuft
|
|
10
|
+
|
|
11
|
+
import type { ConfigEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
12
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
13
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
|
|
15
|
+
import userEvent from "@testing-library/user-event";
|
|
16
|
+
import { describe, expect, test, vi } from "vitest";
|
|
17
|
+
import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
|
|
18
|
+
|
|
19
|
+
const settingsScreen: ConfigEditScreenDefinition = {
|
|
20
|
+
id: "settings",
|
|
21
|
+
type: "configEdit",
|
|
22
|
+
scope: "tenant",
|
|
23
|
+
configKeys: {
|
|
24
|
+
siteName: "demo:config:site-name",
|
|
25
|
+
maxUploadMb: "demo:config:max-upload-mb",
|
|
26
|
+
},
|
|
27
|
+
fields: {
|
|
28
|
+
siteName: { type: "text", required: true },
|
|
29
|
+
maxUploadMb: { type: "number" },
|
|
30
|
+
// @cast-boundary inline schema-author shape — FieldDefinition union too narrow
|
|
31
|
+
} as ConfigEditScreenDefinition["fields"],
|
|
32
|
+
layout: {
|
|
33
|
+
sections: [{ title: "Basics", fields: ["siteName", "maxUploadMb"] }],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const schema: FeatureSchema = {
|
|
38
|
+
featureName: "demo",
|
|
39
|
+
entities: {},
|
|
40
|
+
screens: [settingsScreen],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe("KumikoScreen / configEdit", () => {
|
|
44
|
+
test("loading state while config:query:values is pending", async () => {
|
|
45
|
+
let resolveQuery: (value: unknown) => void = () => {};
|
|
46
|
+
const queryPending = new Promise((resolve) => {
|
|
47
|
+
resolveQuery = resolve;
|
|
48
|
+
});
|
|
49
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
50
|
+
query: (() => queryPending) as unknown as Dispatcher["query"],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
render(
|
|
54
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
55
|
+
<KumikoScreen schema={schema} qn="demo:screen:settings" />
|
|
56
|
+
</DispatcherProvider>,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Solange die query pending ist, rendert ConfigEditBody den
|
|
60
|
+
// Loading-Banner (kein Form, kein vorzeitiges leeres Feld).
|
|
61
|
+
expect(screen.getByTestId("kumiko-screen-loading")).toBeTruthy();
|
|
62
|
+
|
|
63
|
+
// Cleanup: Query auflösen damit der useEffect-Subscriber sauber
|
|
64
|
+
// unmounten kann ohne open-handle-Warning.
|
|
65
|
+
resolveQuery({ isSuccess: true, data: {} });
|
|
66
|
+
await waitFor(() => screen.queryByTestId("render-edit-form"));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("loaded values pre-fill the form (string + numeric coercion)", async () => {
|
|
70
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
71
|
+
query: (async () => ({
|
|
72
|
+
isSuccess: true,
|
|
73
|
+
data: {
|
|
74
|
+
"demo:config:site-name": { value: "Acme", scope: "tenant" },
|
|
75
|
+
"demo:config:max-upload-mb": { value: 25, scope: "tenant" },
|
|
76
|
+
},
|
|
77
|
+
})) as unknown as Dispatcher["query"],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
render(
|
|
81
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
82
|
+
<KumikoScreen schema={schema} qn="demo:screen:settings" />
|
|
83
|
+
</DispatcherProvider>,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
87
|
+
const siteInput = screen.getByTestId("field-siteName").querySelector("input");
|
|
88
|
+
const maxInput = screen.getByTestId("field-maxUploadMb").querySelector("input");
|
|
89
|
+
expect(siteInput?.value).toBe("Acme");
|
|
90
|
+
expect(maxInput?.value).toBe("25");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("submit dispatches one /api/batch with one command per changed field", async () => {
|
|
94
|
+
const batchSpy = vi.fn(
|
|
95
|
+
async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
96
|
+
isSuccess: true as const,
|
|
97
|
+
results: [],
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
101
|
+
query: (async () => ({
|
|
102
|
+
isSuccess: true,
|
|
103
|
+
data: {
|
|
104
|
+
"demo:config:site-name": { value: "Acme", scope: "tenant" },
|
|
105
|
+
"demo:config:max-upload-mb": { value: 25, scope: "tenant" },
|
|
106
|
+
},
|
|
107
|
+
})) as unknown as Dispatcher["query"],
|
|
108
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const user = userEvent.setup();
|
|
112
|
+
render(
|
|
113
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
114
|
+
<KumikoScreen schema={schema} qn="demo:screen:settings" />
|
|
115
|
+
</DispatcherProvider>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
119
|
+
const siteInput = screen.getByTestId("field-siteName").querySelector("input");
|
|
120
|
+
if (!siteInput) throw new Error("expected siteName input");
|
|
121
|
+
|
|
122
|
+
// Ändert NUR siteName — der Batch darf nur EIN Command enthalten,
|
|
123
|
+
// nicht beide (unchanged-Field bleibt aus).
|
|
124
|
+
await user.clear(siteInput);
|
|
125
|
+
await user.type(siteInput, "Globex");
|
|
126
|
+
await user.click(screen.getByTestId("render-edit-submit"));
|
|
127
|
+
|
|
128
|
+
await waitFor(() => expect(batchSpy).toHaveBeenCalled());
|
|
129
|
+
expect(batchSpy).toHaveBeenCalledTimes(1);
|
|
130
|
+
const commands = batchSpy.mock.calls[0]?.[0];
|
|
131
|
+
if (!commands) throw new Error("batchSpy not called");
|
|
132
|
+
expect(commands).toHaveLength(1);
|
|
133
|
+
expect(commands[0]).toEqual({
|
|
134
|
+
type: "config:write:set",
|
|
135
|
+
payload: { key: "demo:config:site-name", value: "Globex", scope: "tenant" },
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("save-button disabled after successful submit (rebase fired)", async () => {
|
|
140
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
141
|
+
query: (async () => ({
|
|
142
|
+
isSuccess: true,
|
|
143
|
+
data: { "demo:config:site-name": { value: "Acme", scope: "tenant" } },
|
|
144
|
+
})) as unknown as Dispatcher["query"],
|
|
145
|
+
batch: (async () => ({
|
|
146
|
+
isSuccess: true,
|
|
147
|
+
results: [],
|
|
148
|
+
})) as unknown as Dispatcher["batch"],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const user = userEvent.setup();
|
|
152
|
+
render(
|
|
153
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
154
|
+
<KumikoScreen schema={schema} qn="demo:screen:settings" />
|
|
155
|
+
</DispatcherProvider>,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
159
|
+
const siteInput = screen.getByTestId("field-siteName").querySelector("input");
|
|
160
|
+
if (!siteInput) throw new Error("expected siteName input");
|
|
161
|
+
|
|
162
|
+
await user.clear(siteInput);
|
|
163
|
+
await user.type(siteInput, "Globex");
|
|
164
|
+
const submit = screen.getByTestId("render-edit-submit") as HTMLButtonElement;
|
|
165
|
+
await user.click(submit);
|
|
166
|
+
|
|
167
|
+
// After rebase, draft == server-snapshot, isUnchanged=true,
|
|
168
|
+
// Button wird disabled. Ohne customSubmit-rebase-Wiring blieb
|
|
169
|
+
// dieser State stale (regression-guard).
|
|
170
|
+
await waitFor(() => expect(submit.disabled).toBe(true));
|
|
171
|
+
});
|
|
172
|
+
});
|