@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.
@@ -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: rendert Spinner statt Children + ist disabled", () => {
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
- // Children verschwinden während loading; Spinner ist ein <svg>.
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("checkbox"));
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
- expect(actionsFooter.className).toContain("max-sm:fixed");
1206
- expect(actionsFooter.className).toContain("flex-wrap");
1207
- const contentContainer = actionsFooter.previousElementSibling as HTMLElement;
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
- expect(actionsFooter.className).not.toContain("max-sm:fixed");
1219
- // Shared cardFooter constant — this asserts the wrap fix (fw#2528) on the
1220
- // plain (non-sticky) footer, which also covers Section/Card since they
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 urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
123
- fireEvent.click(urgentCheckbox as HTMLElement);
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-Feld = vendored Radix-Checkbox → button[role=checkbox], kein
244
- // native input[type=checkbox] mehr.
245
- const urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
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-full when unset", () => {
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-full");
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 urgentCheckbox = screen.getByTestId("field-isUrgent").querySelector('[role="checkbox"]');
2862
- expect((urgentCheckbox as HTMLButtonElement).disabled).toBe(true);
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",