@cosmicdrift/kumiko-renderer-web 0.232.0 → 0.234.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 +5 -5
- package/src/__tests__/app-layout.test.tsx +13 -12
- package/src/__tests__/default-app-shell.test.tsx +33 -0
- package/src/__tests__/form-action-bar.test.tsx +53 -4
- package/src/__tests__/kumiko-screen.test.tsx +293 -3
- package/src/__tests__/primitives.test.tsx +245 -15
- package/src/__tests__/projection-detail-record-layout.test.tsx +91 -0
- package/src/__tests__/render-edit.test.tsx +29 -10
- package/src/__tests__/render-list-narrow-cards.test.tsx +187 -0
- package/src/__tests__/workspace-shell.test.tsx +5 -4
- package/src/icons.tsx +199 -0
- package/src/layout/app-layout.tsx +7 -7
- package/src/layout/default-app-shell.tsx +3 -2
- package/src/layout/nav-tree.tsx +25 -122
- package/src/layout/workspace-shell.tsx +3 -2
- package/src/primitives/__tests__/button.test.tsx +37 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +33 -0
- package/src/primitives/__tests__/select-segmented.test.tsx +84 -0
- package/src/primitives/index.tsx +475 -50
- package/src/primitives/metric.tsx +18 -10
- package/src/styles.css +21 -17
- package/src/ui/switch.tsx +35 -0
|
@@ -15,7 +15,7 @@ import { defaultPrimitives, END_LABEL_MIN_ROWS } from "../primitives";
|
|
|
15
15
|
import { PageSection, Stack } from "../primitives/layout";
|
|
16
16
|
import { fireEvent, render, screen, waitFor } from "./test-utils";
|
|
17
17
|
|
|
18
|
-
const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog, Card } =
|
|
18
|
+
const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog, Card, Section } =
|
|
19
19
|
defaultPrimitives;
|
|
20
20
|
|
|
21
21
|
describe("Button", () => {
|
|
@@ -43,7 +43,7 @@ describe("Button", () => {
|
|
|
43
43
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
test("loading:
|
|
46
|
+
test("loading: Spinner ersetzt icon-Slot, Children bleiben sichtbar (kein Width-Jump) + ist disabled", () => {
|
|
47
47
|
const onClick = mock();
|
|
48
48
|
render(
|
|
49
49
|
<Button loading onClick={onClick} testId="btn">
|
|
@@ -53,10 +53,28 @@ describe("Button", () => {
|
|
|
53
53
|
const btn = screen.getByTestId("btn") as HTMLButtonElement;
|
|
54
54
|
expect(btn.disabled).toBe(true);
|
|
55
55
|
expect(btn.dataset["loading"]).toBe("true");
|
|
56
|
-
|
|
57
|
-
expect(btn.textContent).not.toContain("Save");
|
|
56
|
+
expect(btn.textContent).toContain("Save");
|
|
58
57
|
expect(btn.querySelector("svg")).not.toBeNull();
|
|
59
58
|
});
|
|
59
|
+
|
|
60
|
+
test("icon: rendert ein SVG vor dem Label, kein SVG ohne icon-Prop", () => {
|
|
61
|
+
render(
|
|
62
|
+
<Button icon="trash" testId="btn">
|
|
63
|
+
Delete
|
|
64
|
+
</Button>,
|
|
65
|
+
);
|
|
66
|
+
const btn = screen.getByTestId("btn") as HTMLButtonElement;
|
|
67
|
+
const svg = btn.querySelector("svg");
|
|
68
|
+
expect(svg).not.toBeNull();
|
|
69
|
+
expect(btn.textContent).toBe("Delete");
|
|
70
|
+
// The icon sits before the label text node in DOM order.
|
|
71
|
+
expect(svg?.compareDocumentPosition(btn.lastChild as Node)).toBe(
|
|
72
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
render(<Button testId="btn-no-icon">Delete</Button>);
|
|
76
|
+
expect(screen.getByTestId("btn-no-icon").querySelector("svg")).toBeNull();
|
|
77
|
+
});
|
|
60
78
|
});
|
|
61
79
|
|
|
62
80
|
describe("Banner", () => {
|
|
@@ -160,10 +178,10 @@ describe("Input kind mapping", () => {
|
|
|
160
178
|
expect(onChange).toHaveBeenLastCalledWith(undefined);
|
|
161
179
|
});
|
|
162
180
|
|
|
163
|
-
test('kind="boolean": onChange receives checked', () => {
|
|
181
|
+
test('kind="boolean" outside layout="inline": renders a switch, onChange receives checked', () => {
|
|
164
182
|
const onChange = mock();
|
|
165
183
|
render(<Input id="i" name="i" kind="boolean" value={false} onChange={onChange} />);
|
|
166
|
-
fireEvent.click(screen.getByRole("
|
|
184
|
+
fireEvent.click(screen.getByRole("switch"));
|
|
167
185
|
expect(onChange).toHaveBeenCalledWith(true);
|
|
168
186
|
});
|
|
169
187
|
|
|
@@ -234,6 +252,125 @@ describe("Input kind mapping", () => {
|
|
|
234
252
|
expect(input.className).toContain("text-right");
|
|
235
253
|
expect(document.querySelector("svg[aria-hidden='true']")).not.toBeNull();
|
|
236
254
|
});
|
|
255
|
+
|
|
256
|
+
test('kind="number" unit="km": renders a muted, aria-hidden suffix inside the field and pads the input', () => {
|
|
257
|
+
render(<Input id="i" name="i" kind="number" value={58} unit="km" onChange={() => {}} />);
|
|
258
|
+
const input = screen.getByRole("spinbutton");
|
|
259
|
+
expect(input.className).toContain("pr-8");
|
|
260
|
+
const suffix = screen.getByText("km");
|
|
261
|
+
expect(suffix.getAttribute("aria-hidden")).toBe("true");
|
|
262
|
+
expect((input as HTMLInputElement).value).toBe("58");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('kind="number" without unit: no suffix rendered, no right padding', () => {
|
|
266
|
+
render(<Input id="i" name="i" kind="number" value={58} onChange={() => {}} />);
|
|
267
|
+
expect(screen.queryByText("km")).toBeNull();
|
|
268
|
+
expect(screen.getByRole("spinbutton").className).not.toContain("pr-8");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('kind="number" unit="km": typing only changes the numeric value, the unit never enters it', () => {
|
|
272
|
+
const onChange = mock();
|
|
273
|
+
render(<Input id="i" name="i" kind="number" value={58} unit="km" onChange={onChange} />);
|
|
274
|
+
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "120" } });
|
|
275
|
+
expect(onChange).toHaveBeenCalledWith(120);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('kind="number" icon="hash" unit="km": both decorations render without clobbering each other', () => {
|
|
279
|
+
render(
|
|
280
|
+
<Input id="i" name="i" kind="number" value={58} icon="hash" unit="km" onChange={() => {}} />,
|
|
281
|
+
);
|
|
282
|
+
const input = screen.getByRole("spinbutton");
|
|
283
|
+
expect(input.className).toContain("pl-8");
|
|
284
|
+
expect(input.className).toContain("pr-8");
|
|
285
|
+
expect(document.querySelector("svg[aria-hidden='true']")).not.toBeNull();
|
|
286
|
+
const suffix = screen.getByText("km");
|
|
287
|
+
expect(suffix.getAttribute("aria-hidden")).toBe("true");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Boolean fields render a switch inside a standard (stacked) Field — matching
|
|
292
|
+
// every other field's label-above-control arrangement — while layout="inline"
|
|
293
|
+
// Fields (MultiSelectCheckboxes, the BooleanField widget) keep the checkbox.
|
|
294
|
+
describe("Input kind=boolean: switch vs checkbox", () => {
|
|
295
|
+
test("Field (default layout) + Input(boolean): renders role=switch, not checkbox", () => {
|
|
296
|
+
render(
|
|
297
|
+
<Field id="active" label="Active" testId="field-active">
|
|
298
|
+
<Input id="active" name="active" kind="boolean" value={false} onChange={mock()} />
|
|
299
|
+
</Field>,
|
|
300
|
+
);
|
|
301
|
+
const field = screen.getByTestId("field-active");
|
|
302
|
+
expect(field.querySelector('[role="switch"]')).toBeTruthy();
|
|
303
|
+
expect(field.querySelector('[role="checkbox"]')).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("Field(layout=inline) + Input(boolean): keeps the checkbox", () => {
|
|
307
|
+
render(
|
|
308
|
+
<Field id="opt" label="Option A" layout="inline" testId="field-opt">
|
|
309
|
+
<Input id="opt" name="opt" kind="boolean" value={false} onChange={mock()} />
|
|
310
|
+
</Field>,
|
|
311
|
+
);
|
|
312
|
+
const field = screen.getByTestId("field-opt");
|
|
313
|
+
expect(field.querySelector('[role="checkbox"]')).toBeTruthy();
|
|
314
|
+
expect(field.querySelector('[role="switch"]')).toBeNull();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("clicking the switch reports the inverted value to onChange", () => {
|
|
318
|
+
const onChange = mock();
|
|
319
|
+
render(<Input id="active" name="active" kind="boolean" value={true} onChange={onChange} />);
|
|
320
|
+
fireEvent.click(screen.getByRole("switch"));
|
|
321
|
+
expect(onChange).toHaveBeenCalledWith(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("aria-checked reflects the value, and the switch toggles via keyboard", async () => {
|
|
325
|
+
const onChange = mock();
|
|
326
|
+
const { rerender } = render(
|
|
327
|
+
<Input id="active" name="active" kind="boolean" value={false} onChange={onChange} />,
|
|
328
|
+
);
|
|
329
|
+
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false");
|
|
330
|
+
|
|
331
|
+
rerender(<Input id="active" name="active" kind="boolean" value={true} onChange={onChange} />);
|
|
332
|
+
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("true");
|
|
333
|
+
|
|
334
|
+
const user = userEvent.setup();
|
|
335
|
+
await user.tab();
|
|
336
|
+
expect(document.activeElement).toBe(screen.getByRole("switch"));
|
|
337
|
+
await user.keyboard(" ");
|
|
338
|
+
expect(onChange).toHaveBeenCalledWith(false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("disabled prevents toggling", () => {
|
|
342
|
+
const onChange = mock();
|
|
343
|
+
render(
|
|
344
|
+
<Input id="active" name="active" kind="boolean" value={false} onChange={onChange} disabled />,
|
|
345
|
+
);
|
|
346
|
+
const toggle = screen.getByRole("switch") as HTMLButtonElement;
|
|
347
|
+
expect(toggle.disabled).toBe(true);
|
|
348
|
+
fireEvent.click(toggle);
|
|
349
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("boolean field's label sits above the control, same as a text field's", () => {
|
|
353
|
+
render(
|
|
354
|
+
<>
|
|
355
|
+
<Field id="active" label="Active" testId="field-active">
|
|
356
|
+
<Input id="active" name="active" kind="boolean" value={false} onChange={mock()} />
|
|
357
|
+
</Field>
|
|
358
|
+
<Field id="title" label="Title" testId="field-title">
|
|
359
|
+
<Input id="title" name="title" kind="text" value="" onChange={mock()} />
|
|
360
|
+
</Field>
|
|
361
|
+
</>,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const booleanField = screen.getByTestId("field-active");
|
|
365
|
+
const textField = screen.getByTestId("field-title");
|
|
366
|
+
|
|
367
|
+
// Same structural shape: label-row first, control second — the exact
|
|
368
|
+
// regression the old inline-layout checkbox introduced for booleans.
|
|
369
|
+
expect(booleanField.children[0]?.querySelector("label")).toBeTruthy();
|
|
370
|
+
expect(booleanField.children[1]?.getAttribute("role")).toBe("switch");
|
|
371
|
+
expect(textField.children[0]?.querySelector("label")).toBeTruthy();
|
|
372
|
+
expect(textField.children[1]?.tagName).toBe("INPUT");
|
|
373
|
+
});
|
|
237
374
|
});
|
|
238
375
|
|
|
239
376
|
describe("DataTable", () => {
|
|
@@ -984,6 +1121,76 @@ describe("DataTable", () => {
|
|
|
984
1121
|
// beim Action-Click gleichzeitig zum Edit-Screen navigieren.
|
|
985
1122
|
expect(onRowClick).not.toHaveBeenCalled();
|
|
986
1123
|
});
|
|
1124
|
+
|
|
1125
|
+
// fw-ui-defaults: rowActionMode="inline" renders every action as an
|
|
1126
|
+
// always-visible button (no Kebab, unlike adaptive) — a group with more
|
|
1127
|
+
// than two icon-carrying actions collapses to icon-only so it doesn't
|
|
1128
|
+
// become a wall of text buttons.
|
|
1129
|
+
describe("icon-only collapse (mode='inline', >2 actions, all carry an icon)", () => {
|
|
1130
|
+
test("renders icon-only buttons with an accessible name per action", () => {
|
|
1131
|
+
render(
|
|
1132
|
+
<DataTable
|
|
1133
|
+
columns={cols}
|
|
1134
|
+
rows={rows}
|
|
1135
|
+
testId="dt"
|
|
1136
|
+
rowActionMode="inline"
|
|
1137
|
+
rowActions={[
|
|
1138
|
+
{ id: "edit", label: "Edit", icon: "pencil", onTrigger: mock() },
|
|
1139
|
+
{ id: "archive", label: "Archive", icon: "archive", onTrigger: mock() },
|
|
1140
|
+
{ id: "delete", label: "Delete", icon: "trash", onTrigger: mock() },
|
|
1141
|
+
]}
|
|
1142
|
+
/>,
|
|
1143
|
+
);
|
|
1144
|
+
for (const [id, label] of [
|
|
1145
|
+
["edit", "Edit"],
|
|
1146
|
+
["archive", "Archive"],
|
|
1147
|
+
["delete", "Delete"],
|
|
1148
|
+
] as const) {
|
|
1149
|
+
expect(screen.getByTestId(`row-r1-action-${id}`).getAttribute("aria-label")).toBe(label);
|
|
1150
|
+
}
|
|
1151
|
+
const editButton = screen.getByTestId("row-r1-action-edit");
|
|
1152
|
+
// Icon-only: children (the label text) are gone, only the <svg> renders.
|
|
1153
|
+
expect(editButton.textContent).toBe("");
|
|
1154
|
+
expect(editButton.querySelector("svg")).not.toBeNull();
|
|
1155
|
+
expect(editButton.getAttribute("title")).toBe("Edit");
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
test("a group of exactly two icon actions keeps the text label (rule needs >2)", () => {
|
|
1159
|
+
render(
|
|
1160
|
+
<DataTable
|
|
1161
|
+
columns={cols}
|
|
1162
|
+
rows={rows}
|
|
1163
|
+
testId="dt"
|
|
1164
|
+
rowActionMode="inline"
|
|
1165
|
+
rowActions={[
|
|
1166
|
+
{ id: "edit", label: "Edit", icon: "pencil", onTrigger: mock() },
|
|
1167
|
+
{ id: "delete", label: "Delete", icon: "trash", onTrigger: mock() },
|
|
1168
|
+
]}
|
|
1169
|
+
/>,
|
|
1170
|
+
);
|
|
1171
|
+
expect(screen.getByTestId("row-r1-action-edit").textContent).toContain("Edit");
|
|
1172
|
+
expect(screen.getByTestId("row-r1-action-delete").textContent).toContain("Delete");
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
test("a mixed group (one action without an icon) keeps every label as text", () => {
|
|
1176
|
+
render(
|
|
1177
|
+
<DataTable
|
|
1178
|
+
columns={cols}
|
|
1179
|
+
rows={rows}
|
|
1180
|
+
testId="dt"
|
|
1181
|
+
rowActionMode="inline"
|
|
1182
|
+
rowActions={[
|
|
1183
|
+
{ id: "edit", label: "Edit", icon: "pencil", onTrigger: mock() },
|
|
1184
|
+
{ id: "archive", label: "Archive", icon: "archive", onTrigger: mock() },
|
|
1185
|
+
{ id: "custom", label: "Custom", onTrigger: mock() },
|
|
1186
|
+
]}
|
|
1187
|
+
/>,
|
|
1188
|
+
);
|
|
1189
|
+
expect(screen.getByTestId("row-r1-action-edit").textContent).toContain("Edit");
|
|
1190
|
+
expect(screen.getByTestId("row-r1-action-archive").textContent).toContain("Archive");
|
|
1191
|
+
expect(screen.getByTestId("row-r1-action-custom").textContent).toContain("Custom");
|
|
1192
|
+
});
|
|
1193
|
+
});
|
|
987
1194
|
});
|
|
988
1195
|
|
|
989
1196
|
describe("highlighted column", () => {
|
|
@@ -1202,9 +1409,11 @@ describe("Form", () => {
|
|
|
1202
1409
|
</Form>,
|
|
1203
1410
|
);
|
|
1204
1411
|
const actionsFooter = screen.getByTestId("form-actions");
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
const
|
|
1412
|
+
// stickyActions classes live on the outer footer container, which wraps
|
|
1413
|
+
// the (optional) secondary group and the main actions group together.
|
|
1414
|
+
const footer = actionsFooter.parentElement as HTMLElement;
|
|
1415
|
+
expect(footer.className).toContain("max-sm:fixed");
|
|
1416
|
+
const contentContainer = footer.previousElementSibling as HTMLElement;
|
|
1208
1417
|
expect(contentContainer.className).toContain("max-sm:pb-32");
|
|
1209
1418
|
});
|
|
1210
1419
|
|
|
@@ -1215,12 +1424,9 @@ describe("Form", () => {
|
|
|
1215
1424
|
</Form>,
|
|
1216
1425
|
);
|
|
1217
1426
|
const actionsFooter = screen.getByTestId("form-actions");
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
// render the same constant.
|
|
1222
|
-
expect(actionsFooter.className).toContain("flex-wrap");
|
|
1223
|
-
const contentContainer = actionsFooter.previousElementSibling as HTMLElement;
|
|
1427
|
+
const footer = actionsFooter.parentElement as HTMLElement;
|
|
1428
|
+
expect(footer.className).not.toContain("max-sm:fixed");
|
|
1429
|
+
const contentContainer = footer.previousElementSibling as HTMLElement;
|
|
1224
1430
|
expect(contentContainer.className).not.toContain("max-sm:pb-32");
|
|
1225
1431
|
});
|
|
1226
1432
|
});
|
|
@@ -1467,6 +1673,30 @@ describe("Card", () => {
|
|
|
1467
1673
|
});
|
|
1468
1674
|
});
|
|
1469
1675
|
|
|
1676
|
+
describe("Section", () => {
|
|
1677
|
+
test("declared icon renders left of the title", () => {
|
|
1678
|
+
render(
|
|
1679
|
+
<Section title="Text" icon="tag" testId="s">
|
|
1680
|
+
<span>body</span>
|
|
1681
|
+
</Section>,
|
|
1682
|
+
);
|
|
1683
|
+
const titleEl = screen.getByTestId("s-title");
|
|
1684
|
+
expect(titleEl.querySelector("svg")).not.toBeNull();
|
|
1685
|
+
expect(titleEl.className).toContain("items-center");
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test("no icon → header renders exactly as before (no svg, no layout change)", () => {
|
|
1689
|
+
render(
|
|
1690
|
+
<Section title="Text" testId="s">
|
|
1691
|
+
<span>body</span>
|
|
1692
|
+
</Section>,
|
|
1693
|
+
);
|
|
1694
|
+
const titleEl = screen.getByTestId("s-title");
|
|
1695
|
+
expect(titleEl.querySelector("svg")).toBeNull();
|
|
1696
|
+
expect(titleEl.textContent).toBe("Text");
|
|
1697
|
+
});
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1470
1700
|
describe("Stack", () => {
|
|
1471
1701
|
test("gap-Variante bildet auf die Tailwind-gap-Klasse ab", () => {
|
|
1472
1702
|
render(
|
|
@@ -129,6 +129,69 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
|
|
|
129
129
|
expect(screen.queryByTestId("kumiko-screen-projection-detail-title")).toBeNull();
|
|
130
130
|
expect(screen.queryByTestId("kumiko-screen-projection-detail-metrics")).toBeNull();
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
test("colors the status badge via the StatusTone heuristic for a recognized value", async () => {
|
|
134
|
+
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
135
|
+
...baseScreen,
|
|
136
|
+
header: { title: "tenantName", status: "state" },
|
|
137
|
+
};
|
|
138
|
+
const dispatcher = dispatcherReturning({ ...rowData, state: "overdue" });
|
|
139
|
+
|
|
140
|
+
render(
|
|
141
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
142
|
+
<KumikoScreen
|
|
143
|
+
schema={schemaFor(headerScreen)}
|
|
144
|
+
qn="rentals:screen:rent-detail"
|
|
145
|
+
entityId="rent-1"
|
|
146
|
+
/>
|
|
147
|
+
</DispatcherProvider>,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
151
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-status").className).toContain(
|
|
152
|
+
"text-status-bad",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("an unmapped status value falls back to the muted tone", async () => {
|
|
157
|
+
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
158
|
+
...baseScreen,
|
|
159
|
+
header: { title: "tenantName", status: "state" },
|
|
160
|
+
};
|
|
161
|
+
const dispatcher = dispatcherReturning({ ...rowData, state: "archived" });
|
|
162
|
+
|
|
163
|
+
render(
|
|
164
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
165
|
+
<KumikoScreen
|
|
166
|
+
schema={schemaFor(headerScreen)}
|
|
167
|
+
qn="rentals:screen:rent-detail"
|
|
168
|
+
entityId="rent-1"
|
|
169
|
+
/>
|
|
170
|
+
</DispatcherProvider>,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
174
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-status").className).toContain(
|
|
175
|
+
"text-muted-foreground",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("read-only detail screens render no footer action bar", async () => {
|
|
180
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
181
|
+
|
|
182
|
+
render(
|
|
183
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
184
|
+
<KumikoScreen
|
|
185
|
+
schema={schemaFor(baseScreen)}
|
|
186
|
+
qn="rentals:screen:rent-detail"
|
|
187
|
+
entityId="rent-1"
|
|
188
|
+
/>
|
|
189
|
+
</DispatcherProvider>,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
193
|
+
expect(screen.queryByTestId("render-edit-form-actions")).toBeNull();
|
|
194
|
+
});
|
|
132
195
|
});
|
|
133
196
|
|
|
134
197
|
describe("KumikoScreen / projectionDetail — metric tiles render through the Metric primitive", () => {
|
|
@@ -399,6 +462,30 @@ describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
|
|
|
399
462
|
|
|
400
463
|
expect(setSearchParamsCalls).toContainEqual({ tab: "payments" });
|
|
401
464
|
});
|
|
465
|
+
|
|
466
|
+
test("relatedList tab content renders without its own Section card wrapper", async () => {
|
|
467
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
468
|
+
const { navApi } = navWithTab("payments");
|
|
469
|
+
|
|
470
|
+
render(
|
|
471
|
+
<NavProvider value={navApi}>
|
|
472
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
473
|
+
<KumikoScreen
|
|
474
|
+
schema={schemaFor(tabsScreen)}
|
|
475
|
+
qn="rentals:screen:rent-detail"
|
|
476
|
+
entityId="rent-1"
|
|
477
|
+
/>
|
|
478
|
+
</DispatcherProvider>
|
|
479
|
+
</NavProvider>,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
await waitFor(() =>
|
|
483
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
|
|
484
|
+
);
|
|
485
|
+
// hideTitle (tabs mode) drops the Section wrapper — DataTable already
|
|
486
|
+
// draws its own card frame, so a second nested Card would double it up.
|
|
487
|
+
expect(screen.queryByTestId("related-list-Payments")).toBeNull();
|
|
488
|
+
});
|
|
402
489
|
});
|
|
403
490
|
|
|
404
491
|
// Only guard against a solon-shaped screen (relatedList-heavy) silently regressing.
|
|
@@ -448,6 +535,10 @@ describe("KumikoScreen / projectionDetail — unchanged for a solon-shaped scree
|
|
|
448
535
|
// Contrast for the tabs-mode "form title suppressed" test above — outside
|
|
449
536
|
// tabs mode (hideSectionTitles unset) the form's own title still renders.
|
|
450
537
|
expect(screen.getByTestId("render-edit-form-title")).toBeTruthy();
|
|
538
|
+
// Contrast for the tabs-mode "no Section wrapper" test above — a stacked
|
|
539
|
+
// (non-tabs) relatedList section has a visible title, so it keeps its
|
|
540
|
+
// own Section card.
|
|
541
|
+
expect(screen.getByTestId("related-list-Payments")).toBeTruthy();
|
|
451
542
|
});
|
|
452
543
|
});
|
|
453
544
|
|
|
@@ -119,8 +119,8 @@ describe("RenderEdit", () => {
|
|
|
119
119
|
// notes is hidden (isUrgent=false): title, count, isUrgent → 3 cells.
|
|
120
120
|
expect(grid?.children.length).toBe(3);
|
|
121
121
|
|
|
122
|
-
const
|
|
123
|
-
fireEvent.click(
|
|
122
|
+
const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
|
|
123
|
+
fireEvent.click(urgentSwitch as HTMLElement);
|
|
124
124
|
|
|
125
125
|
// notes becomes visible → 4 cells, no leftover empty cell from before.
|
|
126
126
|
expect(grid?.children.length).toBe(4);
|
|
@@ -240,10 +240,9 @@ describe("RenderEdit", () => {
|
|
|
240
240
|
);
|
|
241
241
|
|
|
242
242
|
expect(screen.queryByTestId("field-notes")).toBeNull();
|
|
243
|
-
// boolean
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
fireEvent.click(urgentCheckbox as HTMLElement);
|
|
243
|
+
// boolean field in a standard form = vendored Radix Switch → button[role=switch].
|
|
244
|
+
const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
|
|
245
|
+
fireEvent.click(urgentSwitch as HTMLElement);
|
|
247
246
|
expect(screen.queryByTestId("field-notes")).toBeTruthy();
|
|
248
247
|
});
|
|
249
248
|
|
|
@@ -284,7 +283,7 @@ describe("RenderEdit", () => {
|
|
|
284
283
|
expect(seenResults[0]?.isSuccess).toBe(true);
|
|
285
284
|
});
|
|
286
285
|
|
|
287
|
-
test("layout.width defaults the form shell to max-w-
|
|
286
|
+
test("layout.width defaults the form shell to max-w-4xl when unset", () => {
|
|
288
287
|
render(
|
|
289
288
|
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
290
289
|
<RenderEdit<TestValues>
|
|
@@ -298,7 +297,7 @@ describe("RenderEdit", () => {
|
|
|
298
297
|
);
|
|
299
298
|
|
|
300
299
|
const shell = screen.getByTestId("render-edit-form").firstElementChild;
|
|
301
|
-
expect(shell?.className).toContain("max-w-
|
|
300
|
+
expect(shell?.className).toContain("max-w-4xl");
|
|
302
301
|
expect(shell?.className).not.toContain("max-w-3xl");
|
|
303
302
|
});
|
|
304
303
|
|
|
@@ -2858,8 +2857,8 @@ describe("RenderEdit locked state (#1896)", () => {
|
|
|
2858
2857
|
expect((titleInput as HTMLInputElement).disabled).toBe(true);
|
|
2859
2858
|
const countInput = screen.getByTestId("field-count").querySelector("input");
|
|
2860
2859
|
expect((countInput as HTMLInputElement).disabled).toBe(true);
|
|
2861
|
-
const
|
|
2862
|
-
expect((
|
|
2860
|
+
const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
|
|
2861
|
+
expect((urgentSwitch as HTMLButtonElement).disabled).toBe(true);
|
|
2863
2862
|
expect((screen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(true);
|
|
2864
2863
|
});
|
|
2865
2864
|
|
|
@@ -2927,6 +2926,26 @@ describe("RenderEdit locked state (#1896)", () => {
|
|
|
2927
2926
|
expect(onDelete).not.toHaveBeenCalled();
|
|
2928
2927
|
});
|
|
2929
2928
|
|
|
2929
|
+
test("Delete sitzt in der secondary-Gruppe, Submit in der Haupt-Gruppe (fw#2568)", () => {
|
|
2930
|
+
render(
|
|
2931
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
2932
|
+
<RenderEdit<TestValues>
|
|
2933
|
+
screen={makeScreen()}
|
|
2934
|
+
entity={orderEntity}
|
|
2935
|
+
featureName="orders"
|
|
2936
|
+
initial={{ title: "Acme", count: 1, isUrgent: false }}
|
|
2937
|
+
writeCommand="order:create"
|
|
2938
|
+
onDelete={async () => {}}
|
|
2939
|
+
/>
|
|
2940
|
+
</DispatcherProvider>,
|
|
2941
|
+
);
|
|
2942
|
+
|
|
2943
|
+
const deleteButton = screen.getByTestId("render-edit-delete");
|
|
2944
|
+
const submitButton = screen.getByTestId("render-edit-submit");
|
|
2945
|
+
expect(deleteButton.closest('[data-testid$="-actions-secondary"]')).not.toBeNull();
|
|
2946
|
+
expect(submitButton.closest('[data-testid$="-actions-secondary"]')).toBeNull();
|
|
2947
|
+
});
|
|
2948
|
+
|
|
2930
2949
|
test("disabled prevents picking a draft candidate from adopting it (fw#1909)", async () => {
|
|
2931
2950
|
const screenDef: EntityEditScreenDefinition = {
|
|
2932
2951
|
id: "orders:screen:order-wizard-locked-draftpicker",
|