@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,1014 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import type {
|
|
3
|
+
ActionFormScreenDefinition,
|
|
4
|
+
EntityDefinition,
|
|
5
|
+
EntityEditScreenDefinition,
|
|
6
|
+
EntityListScreenDefinition,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
9
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
|
|
11
|
+
import userEvent from "@testing-library/user-event";
|
|
12
|
+
import { describe, expect, test, vi } from "vitest";
|
|
13
|
+
import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
14
|
+
|
|
15
|
+
const taskEntity = {
|
|
16
|
+
fields: {
|
|
17
|
+
title: { type: "text", required: true },
|
|
18
|
+
count: { type: "number" },
|
|
19
|
+
done: { type: "boolean" },
|
|
20
|
+
},
|
|
21
|
+
} as unknown as EntityDefinition;
|
|
22
|
+
|
|
23
|
+
const editScreen: EntityEditScreenDefinition = {
|
|
24
|
+
id: "task-edit",
|
|
25
|
+
type: "entityEdit",
|
|
26
|
+
entity: "task",
|
|
27
|
+
layout: {
|
|
28
|
+
sections: [{ title: "Basics", fields: ["title", "count", "done"] }],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const listScreen: EntityListScreenDefinition = {
|
|
33
|
+
id: "task-list",
|
|
34
|
+
type: "entityList",
|
|
35
|
+
entity: "task",
|
|
36
|
+
columns: ["title", "count", "done"],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const schema: FeatureSchema = {
|
|
40
|
+
featureName: "tasks",
|
|
41
|
+
entities: { task: taskEntity },
|
|
42
|
+
screens: [editScreen, listScreen],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function makeDispatcher(overrides: Partial<Dispatcher> = {}): Dispatcher {
|
|
46
|
+
const base = createMockDispatcher({
|
|
47
|
+
query: (async () => ({
|
|
48
|
+
isSuccess: true,
|
|
49
|
+
data: { rows: [], nextCursor: null },
|
|
50
|
+
})) as unknown as Dispatcher["query"],
|
|
51
|
+
});
|
|
52
|
+
return { ...base, ...overrides };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("KumikoScreen", () => {
|
|
56
|
+
test("unknown qn → not-found placeholder", () => {
|
|
57
|
+
render(
|
|
58
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
59
|
+
<KumikoScreen schema={schema} qn="tasks:screen:ghost" />
|
|
60
|
+
</DispatcherProvider>,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.getByTestId("kumiko-screen-not-found")).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("entityEdit → renders RenderEdit form for the screen's entity", () => {
|
|
66
|
+
render(
|
|
67
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
68
|
+
<KumikoScreen schema={schema} qn="tasks:screen:task-edit" />
|
|
69
|
+
</DispatcherProvider>,
|
|
70
|
+
);
|
|
71
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
72
|
+
expect(screen.getByTestId("field-title")).toBeTruthy();
|
|
73
|
+
expect(screen.getByTestId("field-count")).toBeTruthy();
|
|
74
|
+
expect(screen.getByTestId("field-done")).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("entityList → fires useQuery with derived query QN and renders RenderList", async () => {
|
|
78
|
+
const seenTypes: string[] = [];
|
|
79
|
+
const query = vi.fn(async (type: string) => {
|
|
80
|
+
seenTypes.push(type);
|
|
81
|
+
return {
|
|
82
|
+
isSuccess: true,
|
|
83
|
+
data: {
|
|
84
|
+
rows: [{ id: "r1", title: "hello", count: 3, done: false }],
|
|
85
|
+
nextCursor: null,
|
|
86
|
+
},
|
|
87
|
+
} as never;
|
|
88
|
+
});
|
|
89
|
+
const dispatcher = makeDispatcher({
|
|
90
|
+
query: query as unknown as Dispatcher["query"],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
render(
|
|
94
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
95
|
+
<KumikoScreen schema={schema} qn="tasks:screen:task-list" />
|
|
96
|
+
</DispatcherProvider>,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
100
|
+
expect(screen.getByTestId("render-list-table")).toBeTruthy();
|
|
101
|
+
expect(screen.getByTestId("cell-r1-title").textContent).toBe("hello");
|
|
102
|
+
// Derived query QN matches the server-side qualification rule.
|
|
103
|
+
expect(seenTypes).toEqual(["tasks:query:task:list"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("entityEdit with unknown entity on the screen → entity-missing placeholder", () => {
|
|
107
|
+
const brokenScreen: EntityEditScreenDefinition = {
|
|
108
|
+
id: "broken",
|
|
109
|
+
type: "entityEdit",
|
|
110
|
+
entity: "ghost-entity",
|
|
111
|
+
layout: { sections: [{ title: "x", fields: [] }] },
|
|
112
|
+
};
|
|
113
|
+
const brokenSchema: FeatureSchema = {
|
|
114
|
+
...schema,
|
|
115
|
+
screens: [brokenScreen],
|
|
116
|
+
};
|
|
117
|
+
render(
|
|
118
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
119
|
+
<KumikoScreen schema={brokenSchema} qn="tasks:screen:broken" />
|
|
120
|
+
</DispatcherProvider>,
|
|
121
|
+
);
|
|
122
|
+
expect(screen.getByTestId("kumiko-screen-entity-missing")).toBeTruthy();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("entityEdit mit entityId → lädt detail, pre-fillt Form, submit update mit {id,version,changes}", async () => {
|
|
126
|
+
const writeCalls: { type: string; payload: unknown }[] = [];
|
|
127
|
+
const dispatcher = makeDispatcher({
|
|
128
|
+
query: (async () => ({
|
|
129
|
+
isSuccess: true,
|
|
130
|
+
data: { id: "task-1", version: 7, title: "loaded-title", count: 3, done: false },
|
|
131
|
+
})) as unknown as Dispatcher["query"],
|
|
132
|
+
write: (async (type: string, payload: unknown) => {
|
|
133
|
+
writeCalls.push({ type, payload });
|
|
134
|
+
return { isSuccess: true, data: { id: "task-1" } };
|
|
135
|
+
}) as unknown as Dispatcher["write"],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
render(
|
|
139
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
140
|
+
<KumikoScreen schema={schema} qn="tasks:screen:task-edit" entityId="task-1" />
|
|
141
|
+
</DispatcherProvider>,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Zuerst Loading, dann Form mit den geladenen Werten.
|
|
145
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
146
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
147
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
148
|
+
expect(titleInput.value).toBe("loaded-title");
|
|
149
|
+
|
|
150
|
+
// Edit + submit → write command trägt { id, version, changes: {title} }
|
|
151
|
+
fireEvent.change(titleInput, { target: { value: "edited-title" } });
|
|
152
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
153
|
+
|
|
154
|
+
await waitFor(() => expect(writeCalls.length).toBe(1));
|
|
155
|
+
const [call] = writeCalls;
|
|
156
|
+
expect(call?.type).toBe("tasks:write:task:update");
|
|
157
|
+
expect(call?.payload).toEqual({
|
|
158
|
+
id: "task-1",
|
|
159
|
+
version: 7,
|
|
160
|
+
changes: { title: "edited-title" },
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("entityList onRowClick → Callback feuert mit Row-Viewmodel", async () => {
|
|
165
|
+
const clicks: { id: string }[] = [];
|
|
166
|
+
const dispatcher = makeDispatcher({
|
|
167
|
+
query: (async () => ({
|
|
168
|
+
isSuccess: true,
|
|
169
|
+
data: {
|
|
170
|
+
rows: [{ id: "row-1", title: "hello", count: 3, done: false }],
|
|
171
|
+
nextCursor: null,
|
|
172
|
+
},
|
|
173
|
+
})) as unknown as Dispatcher["query"],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
render(
|
|
177
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
178
|
+
<KumikoScreen
|
|
179
|
+
schema={schema}
|
|
180
|
+
qn="tasks:screen:task-list"
|
|
181
|
+
onRowClick={(row) => clicks.push({ id: row.id })}
|
|
182
|
+
/>
|
|
183
|
+
</DispatcherProvider>,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
187
|
+
fireEvent.click(screen.getByTestId("row-row-1"));
|
|
188
|
+
expect(clicks).toEqual([{ id: "row-1" }]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("entityEdit update-mode: Delete-Button öffnet Confirm-Dialog + write('delete')", async () => {
|
|
192
|
+
const user = userEvent.setup();
|
|
193
|
+
const writeCalls: { type: string; payload: unknown }[] = [];
|
|
194
|
+
const dispatcher = makeDispatcher({
|
|
195
|
+
query: (async () => ({
|
|
196
|
+
isSuccess: true,
|
|
197
|
+
data: { id: "task-1", version: 7, title: "loaded", count: 3, done: false },
|
|
198
|
+
})) as unknown as Dispatcher["query"],
|
|
199
|
+
write: (async (type: string, payload: unknown) => {
|
|
200
|
+
writeCalls.push({ type, payload });
|
|
201
|
+
return { isSuccess: true, data: {} };
|
|
202
|
+
}) as unknown as Dispatcher["write"],
|
|
203
|
+
});
|
|
204
|
+
render(
|
|
205
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
206
|
+
<KumikoScreen schema={schema} qn="tasks:screen:task-edit" entityId="task-1" />
|
|
207
|
+
</DispatcherProvider>,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
211
|
+
|
|
212
|
+
// userEvent statt fireEvent: Radix Dialog feuert async State-Updates
|
|
213
|
+
// (Presence/FocusScope/DismissableLayer) — fireEvent würde sie un-
|
|
214
|
+
// gewickelt lassen und mit ~26 act()-Warnings spammen.
|
|
215
|
+
await user.click(screen.getByTestId("render-edit-delete"));
|
|
216
|
+
expect(screen.getByTestId("render-edit-delete-dialog")).toBeTruthy();
|
|
217
|
+
expect(writeCalls.length).toBe(0);
|
|
218
|
+
|
|
219
|
+
await user.click(screen.getByTestId("render-edit-delete-dialog-confirm"));
|
|
220
|
+
await waitFor(() => expect(writeCalls.length).toBe(1));
|
|
221
|
+
expect(writeCalls[0]).toEqual({
|
|
222
|
+
type: "tasks:write:task:delete",
|
|
223
|
+
payload: { id: "task-1" },
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("entityEdit create-mode: kein Delete-Button (keine entity-id → nichts zu löschen)", () => {
|
|
228
|
+
render(
|
|
229
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
230
|
+
<KumikoScreen schema={schema} qn="tasks:screen:task-edit" />
|
|
231
|
+
</DispatcherProvider>,
|
|
232
|
+
);
|
|
233
|
+
expect(screen.queryByTestId("render-edit-delete")).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("entityEdit update-mode: version_conflict → Banner + 'Neu laden' triggert detail-refetch", async () => {
|
|
237
|
+
let detailCalls = 0;
|
|
238
|
+
const dispatcher = makeDispatcher({
|
|
239
|
+
query: (async () => {
|
|
240
|
+
detailCalls += 1;
|
|
241
|
+
return {
|
|
242
|
+
isSuccess: true,
|
|
243
|
+
data: {
|
|
244
|
+
id: "task-1",
|
|
245
|
+
version: detailCalls,
|
|
246
|
+
title: `v${detailCalls}`,
|
|
247
|
+
count: 0,
|
|
248
|
+
done: false,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}) as unknown as Dispatcher["query"],
|
|
252
|
+
write: (async () => ({
|
|
253
|
+
isSuccess: false,
|
|
254
|
+
error: {
|
|
255
|
+
code: "version_conflict",
|
|
256
|
+
httpStatus: 409,
|
|
257
|
+
i18nKey: "errors.versionConflict",
|
|
258
|
+
message: "stale",
|
|
259
|
+
},
|
|
260
|
+
})) as unknown as Dispatcher["write"],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
render(
|
|
264
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
265
|
+
<KumikoScreen schema={schema} qn="tasks:screen:task-edit" entityId="task-1" />
|
|
266
|
+
</DispatcherProvider>,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
270
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
271
|
+
|
|
272
|
+
// Dirty machen damit der Submit überhaupt feuert.
|
|
273
|
+
fireEvent.change(titleInput, { target: { value: "edited" } });
|
|
274
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
275
|
+
|
|
276
|
+
// Banner muss den i18nKey zeigen und einen Reload-Button anbieten.
|
|
277
|
+
await waitFor(() => expect(screen.queryByTestId("render-edit-form-error")).toBeTruthy());
|
|
278
|
+
expect(screen.getByTestId("render-edit-form-error-key").textContent).toBe(
|
|
279
|
+
"errors.versionConflict",
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
expect(detailCalls).toBe(1);
|
|
283
|
+
fireEvent.click(screen.getByTestId("render-edit-form-error-reload"));
|
|
284
|
+
await waitFor(() => expect(detailCalls).toBe(2));
|
|
285
|
+
// Banner verschwindet nach dem Reload.
|
|
286
|
+
expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// RowActions-Mapping (Tier 2.7a Resolution-Layer): pinst dass
|
|
290
|
+
// EntityListBody die Schema-Form (handler-QN, label-i18nKey, payload-
|
|
291
|
+
// builder, visible-Function, confirmLabel) zu DataTableRowAction
|
|
292
|
+
// (translated, dispatcher-resolved) korrekt transformiert. Vorher
|
|
293
|
+
// nur indirekt über DataTable-Tests + manuelle Inspection abgedeckt.
|
|
294
|
+
test("entityList rowActions: Schema → translate + dispatch wiring", async () => {
|
|
295
|
+
const writeCalls: { type: string; payload: unknown }[] = [];
|
|
296
|
+
const dispatcher = makeDispatcher({
|
|
297
|
+
query: (async () => ({
|
|
298
|
+
isSuccess: true,
|
|
299
|
+
data: {
|
|
300
|
+
rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
|
|
301
|
+
nextCursor: null,
|
|
302
|
+
},
|
|
303
|
+
})) as unknown as Dispatcher["query"],
|
|
304
|
+
write: (async (type: string, payload: unknown) => {
|
|
305
|
+
writeCalls.push({ type, payload });
|
|
306
|
+
return { isSuccess: true, data: {} };
|
|
307
|
+
}) as unknown as Dispatcher["write"],
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const screenWithActions: EntityListScreenDefinition = {
|
|
311
|
+
id: "task-list",
|
|
312
|
+
type: "entityList",
|
|
313
|
+
entity: "task",
|
|
314
|
+
columns: ["title"],
|
|
315
|
+
rowActions: [
|
|
316
|
+
{
|
|
317
|
+
id: "archive",
|
|
318
|
+
label: "actions.archive",
|
|
319
|
+
handler: "tasks:write:task:archive",
|
|
320
|
+
payload: (row) => ({ id: row["id"], reason: "manual" }),
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
};
|
|
324
|
+
const schemaWithActions: FeatureSchema = {
|
|
325
|
+
...schema,
|
|
326
|
+
screens: [screenWithActions],
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
render(
|
|
330
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
331
|
+
<KumikoScreen schema={schemaWithActions} qn="tasks:screen:task-list" />
|
|
332
|
+
</DispatcherProvider>,
|
|
333
|
+
);
|
|
334
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
335
|
+
|
|
336
|
+
// Inline-Button mit der ID aus dem Schema. Label kommt durch
|
|
337
|
+
// translate() — fallback ist der Key wenn kein Bundle (test-utils
|
|
338
|
+
// mountet eins mit identity-translator).
|
|
339
|
+
const button = screen.getByTestId("row-r1-action-archive");
|
|
340
|
+
expect(button).toBeTruthy();
|
|
341
|
+
|
|
342
|
+
// Click → dispatcher.write mit handler-QN + custom payload (NICHT
|
|
343
|
+
// default `{id}`, sondern der schema-payload-builder muss greifen).
|
|
344
|
+
fireEvent.click(button);
|
|
345
|
+
await waitFor(() => expect(writeCalls.length).toBe(1));
|
|
346
|
+
expect(writeCalls[0]).toEqual({
|
|
347
|
+
type: "tasks:write:task:archive",
|
|
348
|
+
payload: { id: "r1", reason: "manual" },
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Tier 2.7e-1: rowAction kind="navigate" — Click ruft nav.navigate
|
|
353
|
+
// mit screen-id, ggf. mit URL-Search-Params aus params(row).
|
|
354
|
+
test("entityList rowActions kind=navigate: Click → nav.navigate + setSearchParams", async () => {
|
|
355
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
356
|
+
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
357
|
+
const memoryNav = {
|
|
358
|
+
route: { screenId: "task-list" },
|
|
359
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
360
|
+
replace: () => undefined,
|
|
361
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
362
|
+
searchParams: {},
|
|
363
|
+
setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
|
|
364
|
+
};
|
|
365
|
+
const dispatcher = makeDispatcher({
|
|
366
|
+
query: (async () => ({
|
|
367
|
+
isSuccess: true,
|
|
368
|
+
data: {
|
|
369
|
+
rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
|
|
370
|
+
nextCursor: null,
|
|
371
|
+
},
|
|
372
|
+
})) as unknown as Dispatcher["query"],
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const screenWithNav: EntityListScreenDefinition = {
|
|
376
|
+
id: "task-list",
|
|
377
|
+
type: "entityList",
|
|
378
|
+
entity: "task",
|
|
379
|
+
columns: ["title"],
|
|
380
|
+
rowActions: [
|
|
381
|
+
{
|
|
382
|
+
kind: "navigate",
|
|
383
|
+
id: "edit",
|
|
384
|
+
label: "actions.edit",
|
|
385
|
+
screen: "task-edit",
|
|
386
|
+
params: (row) => ({ taskId: row["id"], priority: 5 }),
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
392
|
+
const user = userEvent.setup();
|
|
393
|
+
render(
|
|
394
|
+
<NavProvider value={memoryNav}>
|
|
395
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
396
|
+
<KumikoScreen
|
|
397
|
+
schema={{ ...schema, screens: [screenWithNav] }}
|
|
398
|
+
qn="tasks:screen:task-list"
|
|
399
|
+
/>
|
|
400
|
+
</DispatcherProvider>
|
|
401
|
+
</NavProvider>,
|
|
402
|
+
);
|
|
403
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
404
|
+
|
|
405
|
+
await user.click(screen.getByTestId("row-r1-action-edit"));
|
|
406
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
407
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
|
|
408
|
+
// params werden zu Strings serialisiert (URL-Layer kennt nur Strings).
|
|
409
|
+
expect(searchParamUpdates).toEqual([{ taskId: "r1", priority: "5" }]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("entityList rowActions kind=navigate ohne params: setSearchParams wird NICHT gerufen", async () => {
|
|
413
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
414
|
+
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
415
|
+
const memoryNav = {
|
|
416
|
+
route: { screenId: "task-list" },
|
|
417
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
418
|
+
replace: () => undefined,
|
|
419
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
420
|
+
searchParams: {},
|
|
421
|
+
setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
|
|
422
|
+
};
|
|
423
|
+
const dispatcher = makeDispatcher({
|
|
424
|
+
query: (async () => ({
|
|
425
|
+
isSuccess: true,
|
|
426
|
+
data: {
|
|
427
|
+
rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
|
|
428
|
+
nextCursor: null,
|
|
429
|
+
},
|
|
430
|
+
})) as unknown as Dispatcher["query"],
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const screenWithNav: EntityListScreenDefinition = {
|
|
434
|
+
id: "task-list",
|
|
435
|
+
type: "entityList",
|
|
436
|
+
entity: "task",
|
|
437
|
+
columns: ["title"],
|
|
438
|
+
rowActions: [{ kind: "navigate", id: "view", label: "actions.view", screen: "task-edit" }],
|
|
439
|
+
};
|
|
440
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
441
|
+
const user = userEvent.setup();
|
|
442
|
+
render(
|
|
443
|
+
<NavProvider value={memoryNav}>
|
|
444
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
445
|
+
<KumikoScreen
|
|
446
|
+
schema={{ ...schema, screens: [screenWithNav] }}
|
|
447
|
+
qn="tasks:screen:task-list"
|
|
448
|
+
/>
|
|
449
|
+
</DispatcherProvider>
|
|
450
|
+
</NavProvider>,
|
|
451
|
+
);
|
|
452
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
453
|
+
|
|
454
|
+
await user.click(screen.getByTestId("row-r1-action-view"));
|
|
455
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
456
|
+
expect(searchParamUpdates).toEqual([]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// toolbarActions: Schema-Form (kind: navigate | writeHandler) →
|
|
460
|
+
// Resolved-Form (onTrigger callback). Pinst beide kinds:
|
|
461
|
+
// - navigate dispatch ein nav.navigate({ screenId })
|
|
462
|
+
// - writeHandler dispatched dispatcher.write(handler, payload?())
|
|
463
|
+
test("entityList toolbarActions navigate-kind: Click → nav.navigate", async () => {
|
|
464
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
465
|
+
const dispatcher = makeDispatcher({
|
|
466
|
+
query: (async () => ({
|
|
467
|
+
isSuccess: true,
|
|
468
|
+
data: { rows: [{ id: "r1", title: "x", count: 0, done: false }], nextCursor: null },
|
|
469
|
+
})) as unknown as Dispatcher["query"],
|
|
470
|
+
});
|
|
471
|
+
const memoryNav = {
|
|
472
|
+
route: { screenId: "task-list" },
|
|
473
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
474
|
+
replace: () => undefined,
|
|
475
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
476
|
+
searchParams: {},
|
|
477
|
+
setSearchParams: () => undefined,
|
|
478
|
+
};
|
|
479
|
+
const screenWithToolbar: EntityListScreenDefinition = {
|
|
480
|
+
id: "task-list",
|
|
481
|
+
type: "entityList",
|
|
482
|
+
entity: "task",
|
|
483
|
+
columns: ["title"],
|
|
484
|
+
toolbarActions: [
|
|
485
|
+
{ kind: "navigate", id: "open", label: "actions.open", screen: "task-edit" },
|
|
486
|
+
],
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
490
|
+
const user = userEvent.setup();
|
|
491
|
+
render(
|
|
492
|
+
<NavProvider value={memoryNav}>
|
|
493
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
494
|
+
<KumikoScreen
|
|
495
|
+
schema={{ ...schema, screens: [screenWithToolbar] }}
|
|
496
|
+
qn="tasks:screen:task-list"
|
|
497
|
+
/>
|
|
498
|
+
</DispatcherProvider>
|
|
499
|
+
</NavProvider>,
|
|
500
|
+
);
|
|
501
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
502
|
+
|
|
503
|
+
await user.click(screen.getByTestId("render-list-toolbar-action-open"));
|
|
504
|
+
expect(navigateCalls).toEqual([{ screenId: "task-edit" }]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("entityList toolbarActions writeHandler-kind: Click → dispatcher.write", async () => {
|
|
508
|
+
const writeCalls: { type: string; payload: unknown }[] = [];
|
|
509
|
+
const dispatcher = makeDispatcher({
|
|
510
|
+
query: (async () => ({
|
|
511
|
+
isSuccess: true,
|
|
512
|
+
data: { rows: [{ id: "r1", title: "x", count: 0, done: false }], nextCursor: null },
|
|
513
|
+
})) as unknown as Dispatcher["query"],
|
|
514
|
+
write: (async (type: string, payload: unknown) => {
|
|
515
|
+
writeCalls.push({ type, payload });
|
|
516
|
+
return { isSuccess: true, data: {} };
|
|
517
|
+
}) as unknown as Dispatcher["write"],
|
|
518
|
+
});
|
|
519
|
+
const screenWithToolbar: EntityListScreenDefinition = {
|
|
520
|
+
id: "task-list",
|
|
521
|
+
type: "entityList",
|
|
522
|
+
entity: "task",
|
|
523
|
+
columns: ["title"],
|
|
524
|
+
toolbarActions: [
|
|
525
|
+
{
|
|
526
|
+
kind: "writeHandler",
|
|
527
|
+
id: "sync",
|
|
528
|
+
label: "actions.sync",
|
|
529
|
+
handler: "tasks:write:task:sync",
|
|
530
|
+
payload: () => ({ all: true }),
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const user = userEvent.setup();
|
|
536
|
+
render(
|
|
537
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
538
|
+
<KumikoScreen
|
|
539
|
+
schema={{ ...schema, screens: [screenWithToolbar] }}
|
|
540
|
+
qn="tasks:screen:task-list"
|
|
541
|
+
/>
|
|
542
|
+
</DispatcherProvider>,
|
|
543
|
+
);
|
|
544
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
545
|
+
|
|
546
|
+
await user.click(screen.getByTestId("render-list-toolbar-action-sync"));
|
|
547
|
+
await waitFor(() => expect(writeCalls.length).toBe(1));
|
|
548
|
+
expect(writeCalls[0]).toEqual({ type: "tasks:write:task:sync", payload: { all: true } });
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Tier 2.7c: Screen-Level filter wird vom Schema in den Query-
|
|
552
|
+
// Payload propagiert. Drei Buckets ("scheduled" / "active" / "done")
|
|
553
|
+
// teilen sich denselben Query-Handler — der Filter unterscheidet
|
|
554
|
+
// welche Rows kommen.
|
|
555
|
+
test("entityList screen-filter: schema.filter landet im query-payload", async () => {
|
|
556
|
+
const queryCalls: { type: string; payload: unknown }[] = [];
|
|
557
|
+
const dispatcher = makeDispatcher({
|
|
558
|
+
query: (async (type: string, payload: unknown) => {
|
|
559
|
+
queryCalls.push({ type, payload });
|
|
560
|
+
return {
|
|
561
|
+
isSuccess: true,
|
|
562
|
+
data: { rows: [], nextCursor: null },
|
|
563
|
+
};
|
|
564
|
+
}) as unknown as Dispatcher["query"],
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const filteredScreen: EntityListScreenDefinition = {
|
|
568
|
+
id: "task-list",
|
|
569
|
+
type: "entityList",
|
|
570
|
+
entity: "task",
|
|
571
|
+
columns: ["title"],
|
|
572
|
+
filter: { field: "status", op: "eq", value: "scheduled" },
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
render(
|
|
576
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
577
|
+
<KumikoScreen
|
|
578
|
+
schema={{ ...schema, screens: [filteredScreen] }}
|
|
579
|
+
qn="tasks:screen:task-list"
|
|
580
|
+
/>
|
|
581
|
+
</DispatcherProvider>,
|
|
582
|
+
);
|
|
583
|
+
await waitFor(() => expect(queryCalls.length).toBeGreaterThan(0));
|
|
584
|
+
|
|
585
|
+
const firstCall = queryCalls[0];
|
|
586
|
+
expect(firstCall?.type).toBe("tasks:query:task:list");
|
|
587
|
+
const payload = firstCall?.payload as { filter?: unknown };
|
|
588
|
+
expect(payload.filter).toEqual({ field: "status", op: "eq", value: "scheduled" });
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// Regression-Guard: Default-Pfad (kein screen.filter) darf KEIN
|
|
592
|
+
// filter-Feld in den queryPayload schicken. Sonst würde Zod-Strict
|
|
593
|
+
// ein leeres `filter: undefined` als 400 abweisen, oder ein
|
|
594
|
+
// "match-none"-Default-Drift entstehen.
|
|
595
|
+
test("entityList ohne screen.filter: queryPayload hat kein filter-Feld", async () => {
|
|
596
|
+
const queryCalls: { type: string; payload: unknown }[] = [];
|
|
597
|
+
const dispatcher = makeDispatcher({
|
|
598
|
+
query: (async (type: string, payload: unknown) => {
|
|
599
|
+
queryCalls.push({ type, payload });
|
|
600
|
+
return {
|
|
601
|
+
isSuccess: true,
|
|
602
|
+
data: { rows: [], nextCursor: null },
|
|
603
|
+
};
|
|
604
|
+
}) as unknown as Dispatcher["query"],
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
render(
|
|
608
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
609
|
+
<KumikoScreen schema={schema} qn="tasks:screen:task-list" />
|
|
610
|
+
</DispatcherProvider>,
|
|
611
|
+
);
|
|
612
|
+
await waitFor(() => expect(queryCalls.length).toBeGreaterThan(0));
|
|
613
|
+
|
|
614
|
+
const payload = queryCalls[0]?.payload as { filter?: unknown };
|
|
615
|
+
expect("filter" in payload).toBe(false);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("entityList rowActions visible-filter: hidden Action erscheint nicht im DOM", async () => {
|
|
619
|
+
const dispatcher = makeDispatcher({
|
|
620
|
+
query: (async () => ({
|
|
621
|
+
isSuccess: true,
|
|
622
|
+
data: {
|
|
623
|
+
rows: [
|
|
624
|
+
{ id: "r1", title: "Open", status: "scheduled", count: 0, done: false },
|
|
625
|
+
{ id: "r2", title: "Done", status: "completed", count: 0, done: true },
|
|
626
|
+
],
|
|
627
|
+
nextCursor: null,
|
|
628
|
+
},
|
|
629
|
+
})) as unknown as Dispatcher["query"],
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const screenWithVisible: EntityListScreenDefinition = {
|
|
633
|
+
id: "task-list",
|
|
634
|
+
type: "entityList",
|
|
635
|
+
entity: "task",
|
|
636
|
+
columns: ["title"],
|
|
637
|
+
rowActions: [
|
|
638
|
+
{
|
|
639
|
+
id: "start",
|
|
640
|
+
label: "actions.start",
|
|
641
|
+
handler: "tasks:write:task:start",
|
|
642
|
+
// Nur sichtbar bei status===scheduled
|
|
643
|
+
visible: (row: unknown) => (row as { status?: string }).status === "scheduled",
|
|
644
|
+
},
|
|
645
|
+
],
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
render(
|
|
649
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
650
|
+
<KumikoScreen
|
|
651
|
+
schema={{ ...schema, screens: [screenWithVisible] }}
|
|
652
|
+
qn="tasks:screen:task-list"
|
|
653
|
+
/>
|
|
654
|
+
</DispatcherProvider>,
|
|
655
|
+
);
|
|
656
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
657
|
+
|
|
658
|
+
expect(screen.queryByTestId("row-r1-action-start")).not.toBeNull();
|
|
659
|
+
expect(screen.queryByTestId("row-r2-action-start")).toBeNull();
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// --- actionForm (Tier 2.7d) ---
|
|
663
|
+
// Non-CRUD Write-Handler-driven Form. Schema deklariert handler-QN +
|
|
664
|
+
// inline fields; Renderer baut darauf den selben RenderEdit-Stack
|
|
665
|
+
// wie entityEdit, aber Submit ruft den deklarierten handler statt
|
|
666
|
+
// <feature>:write:<entity>:create.
|
|
667
|
+
test("actionForm: rendert Form-Felder + Submit triggert dispatcher.write(handler, values)", async () => {
|
|
668
|
+
const writeCalls: { type: string; payload: unknown }[] = [];
|
|
669
|
+
const dispatcher = makeDispatcher({
|
|
670
|
+
write: (async (type: string, payload: unknown) => {
|
|
671
|
+
writeCalls.push({ type, payload });
|
|
672
|
+
return { isSuccess: true, data: { id: "new-id" } };
|
|
673
|
+
}) as unknown as Dispatcher["write"],
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
677
|
+
id: "quick-add",
|
|
678
|
+
type: "actionForm",
|
|
679
|
+
handler: "tasks:write:task:quick-add",
|
|
680
|
+
fields: {
|
|
681
|
+
title: { type: "text", required: true },
|
|
682
|
+
priority: { type: "number", default: 1 },
|
|
683
|
+
},
|
|
684
|
+
layout: { sections: [{ title: "Basics", fields: ["title", "priority"] }] },
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
render(
|
|
688
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
689
|
+
<KumikoScreen schema={{ ...schema, screens: [actionScreen] }} qn="tasks:screen:quick-add" />
|
|
690
|
+
</DispatcherProvider>,
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
694
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
695
|
+
expect(titleInput).toBeTruthy();
|
|
696
|
+
fireEvent.change(titleInput, { target: { value: "New Task" } });
|
|
697
|
+
|
|
698
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
699
|
+
await waitFor(() => expect(writeCalls.length).toBe(1));
|
|
700
|
+
expect(writeCalls[0]?.type).toBe("tasks:write:task:quick-add");
|
|
701
|
+
// payloadMode="values" — alle Form-Werte landen im Payload, nicht
|
|
702
|
+
// nur die geänderten. Defaults (priority=1) bleiben drin.
|
|
703
|
+
expect(writeCalls[0]?.payload).toEqual({ title: "New Task", priority: 1 });
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("actionForm mit redirect: nach success → nav.navigate({screenId: redirect})", async () => {
|
|
707
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
708
|
+
const dispatcher = makeDispatcher({
|
|
709
|
+
write: (async () => ({
|
|
710
|
+
isSuccess: true,
|
|
711
|
+
data: { id: "x" },
|
|
712
|
+
})) as unknown as Dispatcher["write"],
|
|
713
|
+
});
|
|
714
|
+
const memoryNav = {
|
|
715
|
+
route: { screenId: "quick-add" },
|
|
716
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
717
|
+
replace: () => undefined,
|
|
718
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
719
|
+
searchParams: {},
|
|
720
|
+
setSearchParams: () => undefined,
|
|
721
|
+
};
|
|
722
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
723
|
+
id: "quick-add",
|
|
724
|
+
type: "actionForm",
|
|
725
|
+
handler: "tasks:write:task:quick-add",
|
|
726
|
+
fields: { title: { type: "text", required: true } },
|
|
727
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
728
|
+
redirect: "task-list",
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
732
|
+
render(
|
|
733
|
+
<NavProvider value={memoryNav}>
|
|
734
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
735
|
+
<KumikoScreen
|
|
736
|
+
schema={{ ...schema, screens: [actionScreen, listScreen] }}
|
|
737
|
+
qn="tasks:screen:quick-add"
|
|
738
|
+
/>
|
|
739
|
+
</DispatcherProvider>
|
|
740
|
+
</NavProvider>,
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
744
|
+
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
745
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
746
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
747
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// Tier 2.7e-2: URL-Search-Params füllen die actionForm initial values.
|
|
751
|
+
// Use-case: rowAction kind=navigate setzt `?taskId=r1`, das actionForm
|
|
752
|
+
// sieht es beim Mount und pre-fillt das title-Feld.
|
|
753
|
+
test("actionForm initial values: searchParams überschreiben Field-Defaults", async () => {
|
|
754
|
+
const memoryNav = {
|
|
755
|
+
route: { screenId: "approve" },
|
|
756
|
+
navigate: () => undefined,
|
|
757
|
+
replace: () => undefined,
|
|
758
|
+
hrefFor: () => "/x",
|
|
759
|
+
searchParams: { title: "Pre-filled", priority: "9", isDone: "true" },
|
|
760
|
+
setSearchParams: () => undefined,
|
|
761
|
+
};
|
|
762
|
+
const dispatcher = makeDispatcher();
|
|
763
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
764
|
+
id: "approve",
|
|
765
|
+
type: "actionForm",
|
|
766
|
+
handler: "tasks:write:task:approve",
|
|
767
|
+
fields: {
|
|
768
|
+
title: { type: "text", default: "default-title" },
|
|
769
|
+
priority: { type: "number", default: 1 },
|
|
770
|
+
isDone: { type: "boolean", default: false },
|
|
771
|
+
},
|
|
772
|
+
layout: {
|
|
773
|
+
sections: [{ title: "x", fields: ["title", "priority", "isDone"] }],
|
|
774
|
+
},
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
778
|
+
render(
|
|
779
|
+
<NavProvider value={memoryNav}>
|
|
780
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
781
|
+
<KumikoScreen schema={{ ...schema, screens: [actionScreen] }} qn="tasks:screen:approve" />
|
|
782
|
+
</DispatcherProvider>
|
|
783
|
+
</NavProvider>,
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
787
|
+
expect(titleInput.value).toBe("Pre-filled");
|
|
788
|
+
// Number-coercion: "9" → 9. Erfolgreiche Coercion bedeutet das
|
|
789
|
+
// Number-Input zeigt "9" (nicht den default 1).
|
|
790
|
+
const priorityInput = screen
|
|
791
|
+
.getByTestId("field-priority")
|
|
792
|
+
.querySelector("input") as HTMLInputElement;
|
|
793
|
+
expect(priorityInput.value).toBe("9");
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("actionForm initial values: searchParam mit fehlerhafter Number → Default-Fallback", async () => {
|
|
797
|
+
const memoryNav = {
|
|
798
|
+
route: { screenId: "approve" },
|
|
799
|
+
navigate: () => undefined,
|
|
800
|
+
replace: () => undefined,
|
|
801
|
+
hrefFor: () => "/x",
|
|
802
|
+
searchParams: { priority: "not-a-number" },
|
|
803
|
+
setSearchParams: () => undefined,
|
|
804
|
+
};
|
|
805
|
+
const dispatcher = makeDispatcher();
|
|
806
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
807
|
+
id: "approve",
|
|
808
|
+
type: "actionForm",
|
|
809
|
+
handler: "tasks:write:task:approve",
|
|
810
|
+
fields: { priority: { type: "number", default: 7 } },
|
|
811
|
+
layout: { sections: [{ title: "x", fields: ["priority"] }] },
|
|
812
|
+
};
|
|
813
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
814
|
+
render(
|
|
815
|
+
<NavProvider value={memoryNav}>
|
|
816
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
817
|
+
<KumikoScreen schema={{ ...schema, screens: [actionScreen] }} qn="tasks:screen:approve" />
|
|
818
|
+
</DispatcherProvider>
|
|
819
|
+
</NavProvider>,
|
|
820
|
+
);
|
|
821
|
+
const priorityInput = screen
|
|
822
|
+
.getByTestId("field-priority")
|
|
823
|
+
.querySelector("input") as HTMLInputElement;
|
|
824
|
+
expect(priorityInput.value).toBe("7"); // Fallback auf default
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("actionForm submitLabel: i18n-Key landet auf dem Submit-Button (übersteuert default)", () => {
|
|
828
|
+
const dispatcher = makeDispatcher();
|
|
829
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
830
|
+
id: "approve",
|
|
831
|
+
type: "actionForm",
|
|
832
|
+
handler: "tasks:write:task:approve",
|
|
833
|
+
fields: { note: { type: "text" } },
|
|
834
|
+
layout: { sections: [{ title: "x", fields: ["note"] }] },
|
|
835
|
+
submitLabel: "actions.approve",
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
render(
|
|
839
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
840
|
+
<KumikoScreen schema={{ ...schema, screens: [actionScreen] }} qn="tasks:screen:approve" />
|
|
841
|
+
</DispatcherProvider>,
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
// Test-utils mountet einen identity-translator als Fallback —
|
|
845
|
+
// unbekannte Keys returnen den Key selbst, also rendert "actions.approve".
|
|
846
|
+
expect(screen.getByTestId("render-edit-submit").textContent).toBe("actions.approve");
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("actionForm ohne redirect: nach success bleibt der User auf der Form (kein navigate)", async () => {
|
|
850
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
851
|
+
const writeCalls: { type: string; payload: unknown }[] = [];
|
|
852
|
+
const dispatcher = makeDispatcher({
|
|
853
|
+
write: (async (type: string, payload: unknown) => {
|
|
854
|
+
writeCalls.push({ type, payload });
|
|
855
|
+
return { isSuccess: true, data: { id: "x" } };
|
|
856
|
+
}) as unknown as Dispatcher["write"],
|
|
857
|
+
});
|
|
858
|
+
const memoryNav = {
|
|
859
|
+
route: { screenId: "quick-add" },
|
|
860
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
861
|
+
replace: () => undefined,
|
|
862
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
863
|
+
searchParams: {},
|
|
864
|
+
setSearchParams: () => undefined,
|
|
865
|
+
};
|
|
866
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
867
|
+
id: "quick-add",
|
|
868
|
+
type: "actionForm",
|
|
869
|
+
handler: "tasks:write:task:quick-add",
|
|
870
|
+
fields: { title: { type: "text", required: true } },
|
|
871
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
872
|
+
// redirect bewusst NICHT gesetzt
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
876
|
+
render(
|
|
877
|
+
<NavProvider value={memoryNav}>
|
|
878
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
879
|
+
<KumikoScreen
|
|
880
|
+
schema={{ ...schema, screens: [actionScreen] }}
|
|
881
|
+
qn="tasks:screen:quick-add"
|
|
882
|
+
/>
|
|
883
|
+
</DispatcherProvider>
|
|
884
|
+
</NavProvider>,
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
888
|
+
fireEvent.change(titleInput, { target: { value: "stay" } });
|
|
889
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
890
|
+
// Auf den Write-Call warten — sonst racet der Test gegen den
|
|
891
|
+
// async submit und prüft navigate-Calls bevor handleSubmitted
|
|
892
|
+
// überhaupt gerufen wurde (waitFor auf "render-edit-form" wäre
|
|
893
|
+
// ein no-op weil die Form sowieso schon mounted ist).
|
|
894
|
+
await waitFor(() => expect(writeCalls.length).toBe(1));
|
|
895
|
+
expect(navigateCalls).toEqual([]);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test("custom screen type → placeholder (M4 wires r.uiComponent)", () => {
|
|
899
|
+
const customSchema: FeatureSchema = {
|
|
900
|
+
featureName: "tasks",
|
|
901
|
+
entities: { task: taskEntity },
|
|
902
|
+
screens: [
|
|
903
|
+
{
|
|
904
|
+
id: "dashboard",
|
|
905
|
+
type: "custom",
|
|
906
|
+
renderer: { react: "Dashboard" },
|
|
907
|
+
},
|
|
908
|
+
],
|
|
909
|
+
};
|
|
910
|
+
render(
|
|
911
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
912
|
+
<KumikoScreen schema={customSchema} qn="tasks:screen:dashboard" />
|
|
913
|
+
</DispatcherProvider>,
|
|
914
|
+
);
|
|
915
|
+
expect(screen.getByTestId("kumiko-screen-custom-placeholder")).toBeTruthy();
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// ------------------------------------------------------------------
|
|
919
|
+
// Auto-Navigate Targets — die drei Hooks im kumiko-screen-Renderer
|
|
920
|
+
// (useNavigateToCreateFor, useNavigateToListAfter, default
|
|
921
|
+
// onRowClick in create-app) ziehen `screenId` aus `schema.screens[].id`
|
|
922
|
+
// und reichen sie an `nav.navigate({ screenId })` durch. Heute hält
|
|
923
|
+
// die Registry SHORT-form-ids in `feature.screens` (siehe
|
|
924
|
+
// packages/framework/src/engine/registry.ts: feature.screens[shortId]
|
|
925
|
+
// = def). Falls dieser Vertrag jemals kippt (Registry stempelt QN-
|
|
926
|
+
// form ein), strippt `lastSegment` defensiv den Prefix — die Tests
|
|
927
|
+
// pinnen beide Pfade.
|
|
928
|
+
// ------------------------------------------------------------------
|
|
929
|
+
|
|
930
|
+
test("entityList + Neu-Button → navigiert mit screenId aus Schema", async () => {
|
|
931
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
932
|
+
const memoryNav = {
|
|
933
|
+
route: { screenId: "task-list" },
|
|
934
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
935
|
+
replace: () => undefined,
|
|
936
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
937
|
+
searchParams: {},
|
|
938
|
+
setSearchParams: () => undefined,
|
|
939
|
+
};
|
|
940
|
+
const dispatcher = makeDispatcher({
|
|
941
|
+
query: (async () => ({
|
|
942
|
+
isSuccess: true,
|
|
943
|
+
data: { rows: [], nextCursor: null },
|
|
944
|
+
})) as unknown as Dispatcher["query"],
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
948
|
+
render(
|
|
949
|
+
<NavProvider value={memoryNav}>
|
|
950
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
951
|
+
<KumikoScreen schema={schema} qn="tasks:screen:task-list" />
|
|
952
|
+
</DispatcherProvider>
|
|
953
|
+
</NavProvider>,
|
|
954
|
+
);
|
|
955
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
956
|
+
|
|
957
|
+
fireEvent.click(screen.getByTestId("render-list-create"));
|
|
958
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
959
|
+
// Short-Form-id, nicht QN — sonst würde der Browser auf
|
|
960
|
+
// "/tasks:screen:task-edit" landen und der Re-Lookup würde
|
|
961
|
+
// doppelt-qualifizieren.
|
|
962
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test("Auto-Navigate ist defensiv: schema.screens.id mit Doppel-Punkt-Prefix wird gestrippt", async () => {
|
|
966
|
+
// Defense-in-Depth: falls die Registry irgendwann QN-form-ids in
|
|
967
|
+
// schema.screens stamped, würde useNavigateToCreateFor ohne
|
|
968
|
+
// lastSegment einen QN als screenId weiterreichen → URL doppelt-
|
|
969
|
+
// qualifiziert. Test simuliert diesen hypothetischen Fall.
|
|
970
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
971
|
+
const memoryNav = {
|
|
972
|
+
route: { screenId: "task-list" },
|
|
973
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
974
|
+
replace: () => undefined,
|
|
975
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
976
|
+
searchParams: {},
|
|
977
|
+
setSearchParams: () => undefined,
|
|
978
|
+
};
|
|
979
|
+
const dispatcher = makeDispatcher({
|
|
980
|
+
query: (async () => ({
|
|
981
|
+
isSuccess: true,
|
|
982
|
+
data: { rows: [], nextCursor: null },
|
|
983
|
+
})) as unknown as Dispatcher["query"],
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
// Hypothetische QN-form Edit-id; List bleibt short, sonst findet
|
|
987
|
+
// KumikoScreen seine eigene List-Sicht nicht (qualifyScreenId
|
|
988
|
+
// arbeitet immer feature-prefix-style).
|
|
989
|
+
const editScreenQn: EntityEditScreenDefinition = {
|
|
990
|
+
...editScreen,
|
|
991
|
+
id: "tasks:screen:task-edit",
|
|
992
|
+
};
|
|
993
|
+
const mixedSchema: FeatureSchema = {
|
|
994
|
+
...schema,
|
|
995
|
+
screens: [editScreenQn, listScreen],
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
999
|
+
render(
|
|
1000
|
+
<NavProvider value={memoryNav}>
|
|
1001
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1002
|
+
<KumikoScreen schema={mixedSchema} qn="tasks:screen:task-list" />
|
|
1003
|
+
</DispatcherProvider>
|
|
1004
|
+
</NavProvider>,
|
|
1005
|
+
);
|
|
1006
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
1007
|
+
|
|
1008
|
+
fireEvent.click(screen.getByTestId("render-list-create"));
|
|
1009
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1010
|
+
// lastSegment hat den QN-Prefix gestrippt — ohne den Fix würde
|
|
1011
|
+
// hier "tasks:screen:task-edit" stehen.
|
|
1012
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
|
|
1013
|
+
});
|
|
1014
|
+
});
|