@cosmicdrift/kumiko-renderer-web 0.193.0 → 0.194.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__/create-app.test.tsx +1 -1
- package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
- package/src/__tests__/field-hide-label.test.tsx +166 -1
- package/src/__tests__/kumiko-screen.test.tsx +59 -0
- package/src/__tests__/nav-tree.test.tsx +43 -11
- package/src/__tests__/primitives.test.tsx +33 -2
- package/src/__tests__/render-edit.test.tsx +344 -9
- package/src/__tests__/test-utils.tsx +20 -0
- package/src/__tests__/wizard-form-validation.test.tsx +129 -1
- package/src/app/__tests__/merge-content-editors.test.ts +2 -2
- package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
- package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
- package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
- package/src/app/client-plugin.tsx +3 -3
- package/src/app/create-app.tsx +40 -49
- package/src/app/tiptap-editor.tsx +10 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
- package/src/layout/sidebar-panel.tsx +1 -4
- package/src/layout/workspace-switcher.tsx +20 -13
- package/src/lib/__tests__/resize-image.test.ts +152 -0
- package/src/lib/clamp.ts +3 -0
- package/src/lib/resize-image.ts +61 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
- package/src/primitives/__tests__/date-field.test.tsx +37 -0
- package/src/primitives/__tests__/date-parse.test.ts +14 -2
- package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
- package/src/primitives/__tests__/file-upload.test.tsx +24 -0
- package/src/primitives/date-field.tsx +8 -0
- package/src/primitives/date-parse.ts +6 -1
- package/src/primitives/dropdown-menu.tsx +23 -0
- package/src/primitives/embedded-list-input.tsx +14 -5
- package/src/primitives/file-upload.tsx +6 -1
- package/src/primitives/index.tsx +114 -120
- package/src/primitives/money-input.tsx +4 -7
- package/src/ui/sheet.tsx +3 -1
- package/src/widgets/__tests__/drawer.test.tsx +293 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +125 -1
- package/src/widgets/__tests__/widgets.test.tsx +25 -0
- package/src/widgets/ai-text-field.tsx +3 -1
- package/src/widgets/drawer.tsx +54 -29
- package/src/widgets/infinity-list.tsx +18 -5
- package/src/widgets/progress-bar.tsx +3 -1
- package/src/widgets/step-bar.tsx +1 -1
- package/src/widgets/upload-zone.tsx +42 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.194.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": "0.194.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.194.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.194.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",
|
|
@@ -316,7 +316,7 @@ describe("createKumikoApp", () => {
|
|
|
316
316
|
expect(screen.getByTestId("ca-swatch-field").textContent).toBe("color");
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
-
test("clientFeatures.contentEditors → createKumikoApp
|
|
319
|
+
test("clientFeatures.contentEditors → createKumikoApp wires merge → provider → useContentEditor end-to-end", async () => {
|
|
320
320
|
// Proves the whole chain: ClientFeatureDefinition.contentEditors →
|
|
321
321
|
// mergeContentEditors → ContentEditorsProvider in the tree →
|
|
322
322
|
// useContentEditor in the consumer. Without the provider mount,
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
15
15
|
import type { NavTarget } from "@cosmicdrift/kumiko-renderer";
|
|
16
16
|
import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import userEvent from "@testing-library/user-event";
|
|
17
18
|
import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
18
19
|
|
|
19
20
|
const productEntity: EntityDefinition = {
|
|
@@ -163,4 +164,114 @@ describe("entityEdit redirect (#1942)", () => {
|
|
|
163
164
|
|
|
164
165
|
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
|
|
165
166
|
});
|
|
167
|
+
|
|
168
|
+
test("update: redirect as fully-qualified cross-feature QN navigates via its short id (#1946)", async () => {
|
|
169
|
+
// Mirrors the create-mode QN test above — EntityEditUpdateForm's
|
|
170
|
+
// handleSubmitted has its own lastSegment(screen.redirect) call
|
|
171
|
+
// (kumiko-screen.tsx), separate from the create path's.
|
|
172
|
+
const navigated: NavTarget[] = [];
|
|
173
|
+
const dispatcher = createMockDispatcher({
|
|
174
|
+
query: (async () => ({
|
|
175
|
+
isSuccess: true,
|
|
176
|
+
data: { id: "42", version: 1, name: "Existing" },
|
|
177
|
+
})) as unknown as Dispatcher["query"],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
render(
|
|
181
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
182
|
+
<NavProvider
|
|
183
|
+
value={{
|
|
184
|
+
route: { screenId: "shop:screen:product-edit", entityId: "42" },
|
|
185
|
+
navigate: (target) => navigated.push(target),
|
|
186
|
+
replace: () => {},
|
|
187
|
+
hrefFor: () => "",
|
|
188
|
+
searchParams: {},
|
|
189
|
+
setSearchParams: () => {},
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<KumikoScreen
|
|
193
|
+
schema={buildSchema("statements:screen:statement-upload-list")}
|
|
194
|
+
qn="shop:screen:product-edit"
|
|
195
|
+
entityId="42"
|
|
196
|
+
/>
|
|
197
|
+
</NavProvider>
|
|
198
|
+
</DispatcherProvider>,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
|
|
202
|
+
fillNameAndSubmit();
|
|
203
|
+
|
|
204
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "statement-upload-list" }]));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("update: no redirect set falls back to the entity's list screen", async () => {
|
|
208
|
+
const navigated: NavTarget[] = [];
|
|
209
|
+
const dispatcher = createMockDispatcher({
|
|
210
|
+
query: (async () => ({
|
|
211
|
+
isSuccess: true,
|
|
212
|
+
data: { id: "42", version: 1, name: "Existing" },
|
|
213
|
+
})) as unknown as Dispatcher["query"],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
render(
|
|
217
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
218
|
+
<NavProvider
|
|
219
|
+
value={{
|
|
220
|
+
route: { screenId: "shop:screen:product-edit", entityId: "42" },
|
|
221
|
+
navigate: (target) => navigated.push(target),
|
|
222
|
+
replace: () => {},
|
|
223
|
+
hrefFor: () => "",
|
|
224
|
+
searchParams: {},
|
|
225
|
+
setSearchParams: () => {},
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
<KumikoScreen schema={buildSchema()} qn="shop:screen:product-edit" entityId="42" />
|
|
229
|
+
</NavProvider>
|
|
230
|
+
</DispatcherProvider>,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
|
|
234
|
+
fillNameAndSubmit();
|
|
235
|
+
|
|
236
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-list" }]));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("update: delete navigates to the list even with redirect set", async () => {
|
|
240
|
+
const user = userEvent.setup();
|
|
241
|
+
const navigated: NavTarget[] = [];
|
|
242
|
+
const dispatcher = createMockDispatcher({
|
|
243
|
+
query: (async () => ({
|
|
244
|
+
isSuccess: true,
|
|
245
|
+
data: { id: "42", version: 1, name: "Existing" },
|
|
246
|
+
})) as unknown as Dispatcher["query"],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
render(
|
|
250
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
251
|
+
<NavProvider
|
|
252
|
+
value={{
|
|
253
|
+
route: { screenId: "shop:screen:product-edit", entityId: "42" },
|
|
254
|
+
navigate: (target) => navigated.push(target),
|
|
255
|
+
replace: () => {},
|
|
256
|
+
hrefFor: () => "",
|
|
257
|
+
searchParams: {},
|
|
258
|
+
setSearchParams: () => {},
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
<KumikoScreen
|
|
262
|
+
schema={buildSchema("product-detail")}
|
|
263
|
+
qn="shop:screen:product-edit"
|
|
264
|
+
entityId="42"
|
|
265
|
+
/>
|
|
266
|
+
</NavProvider>
|
|
267
|
+
</DispatcherProvider>,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
|
|
271
|
+
// userEvent instead of fireEvent: the Radix dialog fires async state updates.
|
|
272
|
+
await user.click(screen.getByTestId("render-edit-delete"));
|
|
273
|
+
await user.click(screen.getByTestId("render-edit-delete-dialog-confirm"));
|
|
274
|
+
|
|
275
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-list" }]));
|
|
276
|
+
});
|
|
166
277
|
});
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ReactElement } from "react";
|
|
2
4
|
import { defaultPrimitives } from "../primitives";
|
|
3
|
-
import {
|
|
5
|
+
import { AiTextField } from "../widgets/ai-text-field";
|
|
6
|
+
import {
|
|
7
|
+
BooleanField,
|
|
8
|
+
DateField,
|
|
9
|
+
FileField,
|
|
10
|
+
NumberField,
|
|
11
|
+
RangeField,
|
|
12
|
+
SelectField,
|
|
13
|
+
TextareaField,
|
|
14
|
+
TextField,
|
|
15
|
+
} from "../widgets/form-fields";
|
|
16
|
+
import { createMockDispatcher, render } from "./test-utils";
|
|
4
17
|
|
|
5
18
|
const { Field } = defaultPrimitives;
|
|
6
19
|
|
|
@@ -42,3 +55,155 @@ describe("DefaultField hideLabel (fw#1870)", () => {
|
|
|
42
55
|
expect(view.getByLabelText("Maps to")).toBeTruthy();
|
|
43
56
|
});
|
|
44
57
|
});
|
|
58
|
+
|
|
59
|
+
const LABEL = "Maps to";
|
|
60
|
+
|
|
61
|
+
// Every *Field widget in form-fields.tsx wires hideLabel through to the
|
|
62
|
+
// underlying Field primitive by hand (fw#1870/#1871) — a widget that forgets
|
|
63
|
+
// the pass-through renders a visible label with no compile error, so this
|
|
64
|
+
// has to be caught per-widget rather than assumed from the Field test above.
|
|
65
|
+
const FIELD_WIDGETS: ReadonlyArray<readonly [string, ReactElement]> = [
|
|
66
|
+
[
|
|
67
|
+
"NumberField",
|
|
68
|
+
<NumberField
|
|
69
|
+
key="NumberField"
|
|
70
|
+
id="f1"
|
|
71
|
+
name="f1"
|
|
72
|
+
label={LABEL}
|
|
73
|
+
value={1}
|
|
74
|
+
onChange={() => {}}
|
|
75
|
+
hideLabel
|
|
76
|
+
testId="field"
|
|
77
|
+
/>,
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
"TextField",
|
|
81
|
+
<TextField
|
|
82
|
+
key="TextField"
|
|
83
|
+
id="f1"
|
|
84
|
+
name="f1"
|
|
85
|
+
label={LABEL}
|
|
86
|
+
value="hallo"
|
|
87
|
+
onChange={() => {}}
|
|
88
|
+
hideLabel
|
|
89
|
+
testId="field"
|
|
90
|
+
/>,
|
|
91
|
+
],
|
|
92
|
+
[
|
|
93
|
+
"SelectField",
|
|
94
|
+
<SelectField
|
|
95
|
+
key="SelectField"
|
|
96
|
+
id="f1"
|
|
97
|
+
name="f1"
|
|
98
|
+
label={LABEL}
|
|
99
|
+
value="a"
|
|
100
|
+
onChange={() => {}}
|
|
101
|
+
options={["a", "b"]}
|
|
102
|
+
hideLabel
|
|
103
|
+
testId="field"
|
|
104
|
+
/>,
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
"DateField",
|
|
108
|
+
<DateField
|
|
109
|
+
key="DateField"
|
|
110
|
+
id="f1"
|
|
111
|
+
name="f1"
|
|
112
|
+
label={LABEL}
|
|
113
|
+
value="2026-01-01"
|
|
114
|
+
onChange={() => {}}
|
|
115
|
+
hideLabel
|
|
116
|
+
testId="field"
|
|
117
|
+
/>,
|
|
118
|
+
],
|
|
119
|
+
[
|
|
120
|
+
"BooleanField",
|
|
121
|
+
<BooleanField
|
|
122
|
+
key="BooleanField"
|
|
123
|
+
id="f1"
|
|
124
|
+
name="f1"
|
|
125
|
+
label={LABEL}
|
|
126
|
+
value={false}
|
|
127
|
+
onChange={() => {}}
|
|
128
|
+
hideLabel
|
|
129
|
+
testId="field"
|
|
130
|
+
/>,
|
|
131
|
+
],
|
|
132
|
+
[
|
|
133
|
+
"TextareaField",
|
|
134
|
+
<TextareaField
|
|
135
|
+
key="TextareaField"
|
|
136
|
+
id="f1"
|
|
137
|
+
name="f1"
|
|
138
|
+
label={LABEL}
|
|
139
|
+
value="hallo"
|
|
140
|
+
onChange={() => {}}
|
|
141
|
+
hideLabel
|
|
142
|
+
testId="field"
|
|
143
|
+
/>,
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
"RangeField",
|
|
147
|
+
<RangeField
|
|
148
|
+
key="RangeField"
|
|
149
|
+
id="f1"
|
|
150
|
+
name="f1"
|
|
151
|
+
label={LABEL}
|
|
152
|
+
value={5}
|
|
153
|
+
onChange={() => {}}
|
|
154
|
+
min={0}
|
|
155
|
+
max={10}
|
|
156
|
+
hideLabel
|
|
157
|
+
testId="field"
|
|
158
|
+
/>,
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
"FileField",
|
|
162
|
+
<FileField
|
|
163
|
+
key="FileField"
|
|
164
|
+
id="f1"
|
|
165
|
+
name="f1"
|
|
166
|
+
label={LABEL}
|
|
167
|
+
value={null}
|
|
168
|
+
onChange={() => {}}
|
|
169
|
+
hideLabel
|
|
170
|
+
testId="field"
|
|
171
|
+
/>,
|
|
172
|
+
],
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
describe("form-fields.tsx hideLabel pass-through (fw#1870/#1871)", () => {
|
|
176
|
+
test.each(FIELD_WIDGETS)(
|
|
177
|
+
"%s collapses its label to sr-only and keeps it htmlFor-linked",
|
|
178
|
+
(_name, element) => {
|
|
179
|
+
const view = render(element);
|
|
180
|
+
expect(view.getByText(LABEL).className).toContain("sr-only");
|
|
181
|
+
expect(view.getByLabelText(LABEL)).toBeTruthy();
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// AiTextField builds its own <Field> instead of going through form-fields.tsx
|
|
187
|
+
// (fw#1871#1) — a separate widget, so it needs its own pass-through check.
|
|
188
|
+
describe("AiTextField hideLabel pass-through (fw#1871#1)", () => {
|
|
189
|
+
test("collapses its label to sr-only and keeps it htmlFor-linked", () => {
|
|
190
|
+
const dispatcher = createMockDispatcher({});
|
|
191
|
+
const view = render(
|
|
192
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
193
|
+
<AiTextField
|
|
194
|
+
id="f1"
|
|
195
|
+
name="f1"
|
|
196
|
+
label={LABEL}
|
|
197
|
+
value="hallo"
|
|
198
|
+
onChange={() => {}}
|
|
199
|
+
actions={[]}
|
|
200
|
+
completion={false}
|
|
201
|
+
hideLabel
|
|
202
|
+
testId="field"
|
|
203
|
+
/>
|
|
204
|
+
</DispatcherProvider>,
|
|
205
|
+
);
|
|
206
|
+
expect(view.getByText(LABEL).className).toContain("sr-only");
|
|
207
|
+
expect(view.getByLabelText(LABEL)).toBeTruthy();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -1527,6 +1527,38 @@ describe("KumikoScreen", () => {
|
|
|
1527
1527
|
expect(navigateCalls).toEqual([{ screenId: "task-list" }]);
|
|
1528
1528
|
});
|
|
1529
1529
|
|
|
1530
|
+
test("actionForm mit Cross-Feature-QN als cancelTarget: Abbrechen navigiert per Short-Id (#1946)", () => {
|
|
1531
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
1532
|
+
const memoryNav = {
|
|
1533
|
+
route: { screenId: "quick-add" },
|
|
1534
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
1535
|
+
replace: () => undefined,
|
|
1536
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
1537
|
+
searchParams: {},
|
|
1538
|
+
setSearchParams: () => undefined,
|
|
1539
|
+
};
|
|
1540
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
1541
|
+
id: "quick-add",
|
|
1542
|
+
type: "actionForm",
|
|
1543
|
+
handler: "tasks:write:task:quick-add",
|
|
1544
|
+
fields: { title: { type: "text", required: true } },
|
|
1545
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
1546
|
+
cancelTarget: "statements:screen:statement-upload-list",
|
|
1547
|
+
};
|
|
1548
|
+
render(
|
|
1549
|
+
<NavProvider value={memoryNav}>
|
|
1550
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
1551
|
+
<KumikoScreen
|
|
1552
|
+
schema={{ ...schema, screens: [actionScreen, listScreen] }}
|
|
1553
|
+
qn="tasks:screen:quick-add"
|
|
1554
|
+
/>
|
|
1555
|
+
</DispatcherProvider>
|
|
1556
|
+
</NavProvider>,
|
|
1557
|
+
);
|
|
1558
|
+
fireEvent.click(screen.getByTestId("render-edit-cancel"));
|
|
1559
|
+
expect(navigateCalls).toEqual([{ screenId: "statement-upload-list" }]);
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1530
1562
|
// Tier 2.7e-2: URL-Search-Params füllen die actionForm initial values.
|
|
1531
1563
|
// Use-case: rowAction kind=navigate setzt `?taskId=r1`, das actionForm
|
|
1532
1564
|
// sieht es beim Mount und pre-fillt das title-Feld.
|
|
@@ -1956,6 +1988,33 @@ describe("KumikoScreen: singleton entityEdit", () => {
|
|
|
1956
1988
|
expect(screen.getByTestId("kumiko-screen-create-disabled")).toBeTruthy();
|
|
1957
1989
|
expect(screen.queryByTestId("render-edit-form")).toBeNull();
|
|
1958
1990
|
});
|
|
1991
|
+
|
|
1992
|
+
// The singleton wrapper fires its own list(limit:1) query up front — a
|
|
1993
|
+
// screen whose `list` handler has stricter access.roles than its `edit`
|
|
1994
|
+
// handler used to render fine (no query ran before this feature) and now
|
|
1995
|
+
// flips to an error banner instead. No test caught that regression.
|
|
1996
|
+
test("list-Query schlägt fehl → Fehler-Banner statt Create-Form", async () => {
|
|
1997
|
+
const dispatcher = makeDispatcher({
|
|
1998
|
+
query: (async () => ({
|
|
1999
|
+
isSuccess: false,
|
|
2000
|
+
error: {
|
|
2001
|
+
code: "access_denied",
|
|
2002
|
+
httpStatus: 403,
|
|
2003
|
+
i18nKey: "errors.access.denied",
|
|
2004
|
+
message: "",
|
|
2005
|
+
},
|
|
2006
|
+
})) as unknown as Dispatcher["query"],
|
|
2007
|
+
});
|
|
2008
|
+
render(
|
|
2009
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
2010
|
+
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
2011
|
+
</DispatcherProvider>,
|
|
2012
|
+
);
|
|
2013
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2014
|
+
const bannerText = screen.getByTestId("kumiko-screen-error").textContent;
|
|
2015
|
+
expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
|
|
2016
|
+
expect(screen.queryByTestId("render-edit-form")).toBeNull();
|
|
2017
|
+
});
|
|
1959
2018
|
});
|
|
1960
2019
|
|
|
1961
2020
|
// --- actionForm extension-section (Wave J: Incident-Update-Timeline) ---
|
|
@@ -138,6 +138,36 @@ describe("NavTree role-gating", () => {
|
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
// Lucide tags every icon's <svg> with a `lucide-<slug>` class (see
|
|
142
|
+
// createLucideIcon) — asserting on it distinguishes "icon X rendered" from
|
|
143
|
+
// "some svg rendered", so a NAV_ICONS entry pointing at the wrong component
|
|
144
|
+
// (or a duplicate) fails even when the total svg count still matches.
|
|
145
|
+
const NAV_ICON_LUCIDE_CLASS: Readonly<Record<string, string>> = {
|
|
146
|
+
dashboard: "lucide-layout-dashboard",
|
|
147
|
+
layers: "lucide-layers",
|
|
148
|
+
building: "lucide-building",
|
|
149
|
+
tag: "lucide-tag",
|
|
150
|
+
key: "lucide-key-round",
|
|
151
|
+
server: "lucide-server",
|
|
152
|
+
mail: "lucide-mail",
|
|
153
|
+
download: "lucide-download",
|
|
154
|
+
upload: "lucide-upload",
|
|
155
|
+
rocket: "lucide-rocket",
|
|
156
|
+
palette: "lucide-palette",
|
|
157
|
+
link: "lucide-link",
|
|
158
|
+
share: "lucide-share-2",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function expectNavIcons(container: HTMLElement, iconKeys: readonly string[]): void {
|
|
162
|
+
const classes = Array.from(container.querySelectorAll("svg")).map(
|
|
163
|
+
(svg) => svg.getAttribute("class") ?? "",
|
|
164
|
+
);
|
|
165
|
+
expect(classes).toHaveLength(iconKeys.length);
|
|
166
|
+
iconKeys.forEach((key, i) => {
|
|
167
|
+
expect(classes[i]).toContain(NAV_ICON_LUCIDE_CLASS[key]);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
141
171
|
describe("NavTree", () => {
|
|
142
172
|
test("Section-Header (parent ohne screen) ist statisches Label, children sichtbar", () => {
|
|
143
173
|
render(<NavTree schema={makeSchema()} testId="tree" />);
|
|
@@ -204,10 +234,10 @@ describe("NavTree", () => {
|
|
|
204
234
|
const { container } = render(<NavTree schema={schema} />);
|
|
205
235
|
// Flache Navigation ohne Sections → keine Chevrons. Genau EIN svg:
|
|
206
236
|
// das dashboard-Icon. Das icon-lose Item rendert den Dot (span, kein svg).
|
|
207
|
-
|
|
237
|
+
expectNavIcons(container, ["dashboard"]);
|
|
208
238
|
});
|
|
209
239
|
|
|
210
|
-
test("layers + building (money-horse
|
|
240
|
+
test("layers + building (money-horse credit groups/tenants) resolve to an icon", () => {
|
|
211
241
|
const schema = {
|
|
212
242
|
featureName: "showcase",
|
|
213
243
|
entities: {},
|
|
@@ -221,10 +251,10 @@ describe("NavTree", () => {
|
|
|
221
251
|
],
|
|
222
252
|
} as FeatureSchema;
|
|
223
253
|
const { container } = render(<NavTree schema={schema} />);
|
|
224
|
-
|
|
254
|
+
expectNavIcons(container, ["layers", "building"]);
|
|
225
255
|
});
|
|
226
256
|
|
|
227
|
-
test("tag + key (custom-fields / api-tokens)
|
|
257
|
+
test("tag + key (custom-fields / api-tokens) resolve to an icon", () => {
|
|
228
258
|
const schema = {
|
|
229
259
|
featureName: "showcase",
|
|
230
260
|
entities: {},
|
|
@@ -238,13 +268,15 @@ describe("NavTree", () => {
|
|
|
238
268
|
],
|
|
239
269
|
} as FeatureSchema;
|
|
240
270
|
const { container } = render(<NavTree schema={schema} />);
|
|
241
|
-
|
|
271
|
+
expectNavIcons(container, ["tag", "key"]);
|
|
242
272
|
});
|
|
243
273
|
|
|
244
|
-
test("server, mail, download, upload, rocket
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
274
|
+
test("server, mail, download, upload, rocket resolve to an icon (config/SMTP nav)", () => {
|
|
275
|
+
// The config settings hub derives child nav icons from a ConfigKey's
|
|
276
|
+
// mask.icon (smtp-host="server", from="mail", subscription="rocket");
|
|
277
|
+
// a missing NAV_ICONS entry renders the nav blank instead of an icon.
|
|
278
|
+
// download/upload guard the same map against feature-authored icons
|
|
279
|
+
// (e.g. GDPR export-job nav).
|
|
248
280
|
const schema = {
|
|
249
281
|
featureName: "app",
|
|
250
282
|
entities: {},
|
|
@@ -264,7 +296,7 @@ describe("NavTree", () => {
|
|
|
264
296
|
],
|
|
265
297
|
} as FeatureSchema;
|
|
266
298
|
const { container } = render(<NavTree schema={schema} />);
|
|
267
|
-
|
|
299
|
+
expectNavIcons(container, ["server", "mail", "download", "upload", "rocket"]);
|
|
268
300
|
});
|
|
269
301
|
|
|
270
302
|
test("palette, link und share rendern Lucide-Icons (Share-/Branding-Nav)", () => {
|
|
@@ -283,7 +315,7 @@ describe("NavTree", () => {
|
|
|
283
315
|
],
|
|
284
316
|
} as FeatureSchema;
|
|
285
317
|
const { container } = render(<NavTree schema={schema} />);
|
|
286
|
-
|
|
318
|
+
expectNavIcons(container, ["palette", "link", "share"]);
|
|
287
319
|
});
|
|
288
320
|
|
|
289
321
|
test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg)", () => {
|
|
@@ -87,6 +87,21 @@ describe("Banner", () => {
|
|
|
87
87
|
const slot = screen.getByTestId("b").querySelector('[data-slot="actions"]');
|
|
88
88
|
expect(slot?.textContent).toBe("undo");
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
test("id prop (Field-as-control usage) sets role=group + aria-labelledby, not a bare id/htmlFor pairing", () => {
|
|
92
|
+
render(
|
|
93
|
+
<Field id="unsupported-field" label="Raw data">
|
|
94
|
+
<Banner id="unsupported-field" variant="info" testId="b">
|
|
95
|
+
No editor for this field type
|
|
96
|
+
</Banner>
|
|
97
|
+
</Field>,
|
|
98
|
+
);
|
|
99
|
+
const banner = screen.getByTestId("b");
|
|
100
|
+
expect(banner.getAttribute("role")).toBe("group");
|
|
101
|
+
const labelledBy = banner.getAttribute("aria-labelledby");
|
|
102
|
+
expect(labelledBy).not.toBeNull();
|
|
103
|
+
expect(document.getElementById(labelledBy ?? "")?.textContent).toContain("Raw data");
|
|
104
|
+
});
|
|
90
105
|
});
|
|
91
106
|
|
|
92
107
|
describe("Field", () => {
|
|
@@ -893,6 +908,16 @@ describe("DataTable", () => {
|
|
|
893
908
|
expect(screen.getByTestId("cell-r1-y2026").getAttribute("data-highlighted")).toBe("true");
|
|
894
909
|
expect(screen.getByTestId("cell-r1-name").getAttribute("data-highlighted")).toBeNull();
|
|
895
910
|
});
|
|
911
|
+
|
|
912
|
+
test("marks the header of a highlighted sortable column too", () => {
|
|
913
|
+
const sortableCols = [
|
|
914
|
+
{ field: "name", label: "Name", type: "string", sortable: false },
|
|
915
|
+
{ field: "y2026", label: "2026", type: "number", sortable: true, highlighted: true },
|
|
916
|
+
] as const;
|
|
917
|
+
render(<DataTable columns={sortableCols} rows={oneRow} onSortChange={mock()} />);
|
|
918
|
+
expect(screen.getByTestId("column-y2026").getAttribute("data-highlighted")).toBe("true");
|
|
919
|
+
expect(screen.getByTestId("column-name").getAttribute("data-highlighted")).toBeNull();
|
|
920
|
+
});
|
|
896
921
|
});
|
|
897
922
|
|
|
898
923
|
describe("testId overrides", () => {
|
|
@@ -1052,7 +1077,10 @@ describe("Form", () => {
|
|
|
1052
1077
|
<div>content</div>
|
|
1053
1078
|
</Form>,
|
|
1054
1079
|
);
|
|
1055
|
-
|
|
1080
|
+
const actionsFooter = screen.getByTestId("form-actions");
|
|
1081
|
+
expect(actionsFooter.className).toContain("max-sm:fixed");
|
|
1082
|
+
const contentContainer = actionsFooter.previousElementSibling as HTMLElement;
|
|
1083
|
+
expect(contentContainer.className).toContain("max-sm:pb-24");
|
|
1056
1084
|
});
|
|
1057
1085
|
|
|
1058
1086
|
test("ohne stickyActions: Footer bleibt im normalen Dokumentfluss", () => {
|
|
@@ -1061,7 +1089,10 @@ describe("Form", () => {
|
|
|
1061
1089
|
<div>content</div>
|
|
1062
1090
|
</Form>,
|
|
1063
1091
|
);
|
|
1064
|
-
|
|
1092
|
+
const actionsFooter = screen.getByTestId("form-actions");
|
|
1093
|
+
expect(actionsFooter.className).not.toContain("max-sm:fixed");
|
|
1094
|
+
const contentContainer = actionsFooter.previousElementSibling as HTMLElement;
|
|
1095
|
+
expect(contentContainer.className).not.toContain("max-sm:pb-24");
|
|
1065
1096
|
});
|
|
1066
1097
|
});
|
|
1067
1098
|
|