@cosmicdrift/kumiko-renderer-web 0.183.2 → 0.185.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__/nav-tree.test.tsx +5 -3
- package/src/index.ts +2 -0
- package/src/layout/nav-tree.tsx +2 -0
- package/src/primitives/__tests__/button.test.tsx +27 -0
- package/src/primitives/__tests__/embedded-list-input.test.tsx +583 -0
- package/src/primitives/embedded-list-input.tsx +694 -0
- package/src/primitives/index.tsx +11 -5
- package/src/widgets/__tests__/infinity-list.test.tsx +238 -1
- package/src/widgets/__tests__/upload-zone.test.tsx +57 -0
- package/src/widgets/index.ts +1 -0
- package/src/widgets/infinity-list.tsx +63 -5
- package/src/widgets/upload-zone.tsx +158 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
// embedded-list-input Tests — happy-dom + @testing-library/react. Every
|
|
2
|
+
// test interacts with the rendered DOM (click/type/paste) and asserts on
|
|
3
|
+
// the outcome; none merely check that the component mounts.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
6
|
+
import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import type { EmbeddedListColumn, EmbeddedListInputProps } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import {
|
|
9
|
+
createStaticLocaleResolver,
|
|
10
|
+
kumikoDefaultTranslations,
|
|
11
|
+
LocaleProvider,
|
|
12
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
13
|
+
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
14
|
+
import { type ReactElement, useState } from "react";
|
|
15
|
+
import { EmbeddedListInput } from "../embedded-list-input";
|
|
16
|
+
|
|
17
|
+
function renderWithLocale(ui: ReactElement) {
|
|
18
|
+
return render(
|
|
19
|
+
<LocaleProvider
|
|
20
|
+
resolver={createStaticLocaleResolver()}
|
|
21
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
22
|
+
>
|
|
23
|
+
{ui}
|
|
24
|
+
</LocaleProvider>,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const COLUMNS: readonly EmbeddedListColumn[] = [
|
|
29
|
+
{ field: "description", label: "Description", type: "text", required: true, derived: false },
|
|
30
|
+
{ field: "quantity", label: "Qty", type: "number", required: true, derived: false },
|
|
31
|
+
{
|
|
32
|
+
field: "amount",
|
|
33
|
+
label: "Amount",
|
|
34
|
+
type: "money",
|
|
35
|
+
required: false,
|
|
36
|
+
derived: true,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const LABELS = {
|
|
41
|
+
addLabel: "Add row",
|
|
42
|
+
removeLabel: "Remove row",
|
|
43
|
+
duplicateLabel: "Duplicate row",
|
|
44
|
+
moveUpLabel: "Move up",
|
|
45
|
+
moveDownLabel: "Move down",
|
|
46
|
+
emptyLabel: "No lines yet",
|
|
47
|
+
emptyCtaLabel: "Add first line",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function baseProps(overrides: Partial<EmbeddedListInputProps> = {}): EmbeddedListInputProps {
|
|
51
|
+
return {
|
|
52
|
+
id: "lines",
|
|
53
|
+
columns: COLUMNS,
|
|
54
|
+
rows: [],
|
|
55
|
+
onCellChange: () => {},
|
|
56
|
+
onAddRow: () => {},
|
|
57
|
+
onRemoveRow: () => {},
|
|
58
|
+
onDuplicateRow: () => {},
|
|
59
|
+
onMoveRow: () => {},
|
|
60
|
+
testId: "lines",
|
|
61
|
+
...LABELS,
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A real onAddRow that appends a row via useState, wired to the same
|
|
67
|
+
// pendingFocusCellId batching #1839 relies on — a mock onAddRow that
|
|
68
|
+
// never actually grows `rows` can't reproduce the auto-focus effect,
|
|
69
|
+
// since it targets a cell in a row that doesn't exist yet.
|
|
70
|
+
function ControlledFocusHarness({
|
|
71
|
+
columns,
|
|
72
|
+
initialRows,
|
|
73
|
+
}: {
|
|
74
|
+
readonly columns: readonly EmbeddedListColumn[];
|
|
75
|
+
readonly initialRows: ReadonlyArray<Record<string, unknown>>;
|
|
76
|
+
}) {
|
|
77
|
+
const [rows, setRows] = useState(initialRows);
|
|
78
|
+
return (
|
|
79
|
+
<EmbeddedListInput
|
|
80
|
+
{...baseProps({
|
|
81
|
+
columns,
|
|
82
|
+
rows,
|
|
83
|
+
onAddRow: () => setRows((current) => [...current, {}]),
|
|
84
|
+
})}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("EmbeddedListInput — header + rows", () => {
|
|
90
|
+
test("renders one header cell per column and one row per data row, with values", () => {
|
|
91
|
+
const rows = [
|
|
92
|
+
{ description: "Widget A", quantity: 2, amount: 1000 },
|
|
93
|
+
{ description: "Widget B", quantity: 5, amount: 2500 },
|
|
94
|
+
];
|
|
95
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
96
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
97
|
+
expect(desktop.getByText("Description")).toBeTruthy();
|
|
98
|
+
expect(desktop.getByText("Qty")).toBeTruthy();
|
|
99
|
+
expect(desktop.getByText("Amount")).toBeTruthy();
|
|
100
|
+
|
|
101
|
+
const row0 = within(desktop.getByTestId("lines-row-0"));
|
|
102
|
+
expect((row0.getByDisplayValue("Widget A") as HTMLInputElement).value).toBe("Widget A");
|
|
103
|
+
expect((row0.getByDisplayValue("2") as HTMLInputElement).value).toBe("2");
|
|
104
|
+
|
|
105
|
+
const row1 = within(desktop.getByTestId("lines-row-1"));
|
|
106
|
+
expect((row1.getByDisplayValue("Widget B") as HTMLInputElement).value).toBe("Widget B");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("EmbeddedListInput — row mutation callbacks", () => {
|
|
111
|
+
const rows = [
|
|
112
|
+
{ description: "A", quantity: 1, amount: 100 },
|
|
113
|
+
{ description: "B", quantity: 2, amount: 200 },
|
|
114
|
+
{ description: "C", quantity: 3, amount: 300 },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
test("duplicate/remove/move fire with the clicked row's index", () => {
|
|
118
|
+
const onDuplicateRow = mock((_i: number) => {});
|
|
119
|
+
const onRemoveRow = mock((_i: number) => {});
|
|
120
|
+
const onMoveRow = mock((_from: number, _to: number) => {});
|
|
121
|
+
renderWithLocale(
|
|
122
|
+
<EmbeddedListInput {...baseProps({ rows, onDuplicateRow, onRemoveRow, onMoveRow })} />,
|
|
123
|
+
);
|
|
124
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
125
|
+
|
|
126
|
+
fireEvent.click(desktop.getByTestId("lines-row-1-duplicate"));
|
|
127
|
+
expect(onDuplicateRow).toHaveBeenLastCalledWith(1);
|
|
128
|
+
|
|
129
|
+
fireEvent.click(desktop.getByTestId("lines-row-1-move-up"));
|
|
130
|
+
expect(onMoveRow).toHaveBeenLastCalledWith(1, 0);
|
|
131
|
+
|
|
132
|
+
fireEvent.click(desktop.getByTestId("lines-row-1-move-down"));
|
|
133
|
+
expect(onMoveRow).toHaveBeenLastCalledWith(1, 2);
|
|
134
|
+
|
|
135
|
+
fireEvent.click(desktop.getByTestId("lines-row-1-remove"));
|
|
136
|
+
expect(onRemoveRow).toHaveBeenLastCalledWith(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("onCellChange fires with rowIndex/field/value on edit", () => {
|
|
140
|
+
const onCellChange = mock((_r: number, _f: string, _v: unknown) => {});
|
|
141
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onCellChange })} />);
|
|
142
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
143
|
+
const input = within(desktop.getByTestId("lines-row-0")).getByDisplayValue("A");
|
|
144
|
+
fireEvent.change(input, { target: { value: "Updated" } });
|
|
145
|
+
expect(onCellChange).toHaveBeenCalledWith(0, "description", "Updated");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("EmbeddedListInput — min/max item limits", () => {
|
|
150
|
+
const rows = [
|
|
151
|
+
{ description: "A", quantity: 1, amount: 100 },
|
|
152
|
+
{ description: "B", quantity: 2, amount: 200 },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
test("add row is disabled once maxItems is reached", () => {
|
|
156
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, maxItems: 2 })} />);
|
|
157
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
158
|
+
expect((desktop.getByTestId("lines-add") as HTMLButtonElement).disabled).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("add row stays enabled below maxItems", () => {
|
|
162
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, maxItems: 5 })} />);
|
|
163
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
164
|
+
expect((desktop.getByTestId("lines-add") as HTMLButtonElement).disabled).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("remove is disabled once row count would drop below minItems", () => {
|
|
168
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, minItems: 2 })} />);
|
|
169
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
170
|
+
expect((desktop.getByTestId("lines-row-0-remove") as HTMLButtonElement).disabled).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("remove stays enabled above minItems", () => {
|
|
174
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, minItems: 1 })} />);
|
|
175
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
176
|
+
expect((desktop.getByTestId("lines-row-0-remove") as HTMLButtonElement).disabled).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("EmbeddedListInput — derived cells", () => {
|
|
181
|
+
test("a derived column renders its cell disabled", () => {
|
|
182
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
183
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
184
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
185
|
+
const amountCell = desktop.getByTestId("lines-cell-0-amount");
|
|
186
|
+
const input = amountCell.querySelector("input");
|
|
187
|
+
if (input === null) throw new Error("expected an <input> inside the amount cell");
|
|
188
|
+
expect(input.disabled).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("EmbeddedListInput — issue rendering", () => {
|
|
193
|
+
function issue(path: string, message: string): FieldIssue {
|
|
194
|
+
return { path, code: "custom", i18nKey: message };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test("cellIssues render under the matching cell", () => {
|
|
198
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
199
|
+
renderWithLocale(
|
|
200
|
+
<EmbeddedListInput
|
|
201
|
+
{...baseProps({
|
|
202
|
+
rows,
|
|
203
|
+
cellIssues: { "0.description": [issue("lines.0.description", "Required")] },
|
|
204
|
+
})}
|
|
205
|
+
/>,
|
|
206
|
+
);
|
|
207
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
208
|
+
expect(
|
|
209
|
+
within(desktop.getByTestId("lines-cell-0-description-errors")).getByText("Required"),
|
|
210
|
+
).toBeTruthy();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("rowIssues render under the matching row", () => {
|
|
214
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
215
|
+
renderWithLocale(
|
|
216
|
+
<EmbeddedListInput
|
|
217
|
+
{...baseProps({
|
|
218
|
+
rows,
|
|
219
|
+
rowIssues: { 0: [issue("lines.0", "Row incomplete")] },
|
|
220
|
+
})}
|
|
221
|
+
/>,
|
|
222
|
+
);
|
|
223
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
224
|
+
expect(
|
|
225
|
+
within(desktop.getByTestId("lines-row-0-issues")).getByText("Row incomplete"),
|
|
226
|
+
).toBeTruthy();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("listIssues render at the list level", () => {
|
|
230
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
231
|
+
renderWithLocale(
|
|
232
|
+
<EmbeddedListInput
|
|
233
|
+
{...baseProps({
|
|
234
|
+
rows,
|
|
235
|
+
listIssues: [issue("lines", "Too few lines")],
|
|
236
|
+
})}
|
|
237
|
+
/>,
|
|
238
|
+
);
|
|
239
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
240
|
+
expect(
|
|
241
|
+
within(desktop.getByTestId("lines-list-issues")).getByText("Too few lines"),
|
|
242
|
+
).toBeTruthy();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("EmbeddedListInput — empty state", () => {
|
|
247
|
+
test("shows emptyLabel + CTA button, click fires onAddRow", () => {
|
|
248
|
+
const onAddRow = mock(() => {});
|
|
249
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows: [], onAddRow })} />);
|
|
250
|
+
expect(screen.getByText("No lines yet")).toBeTruthy();
|
|
251
|
+
const cta = screen.getByTestId("lines-empty-add");
|
|
252
|
+
expect(within(cta).getByText("Add first line")).toBeTruthy();
|
|
253
|
+
fireEvent.click(cta);
|
|
254
|
+
expect(onAddRow).toHaveBeenCalledTimes(1);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("EmbeddedListInput — totals", () => {
|
|
259
|
+
test("renders each total's label and value", () => {
|
|
260
|
+
const rows = [{ description: "A", quantity: 1, amount: 1234 }];
|
|
261
|
+
renderWithLocale(
|
|
262
|
+
<EmbeddedListInput
|
|
263
|
+
{...baseProps({
|
|
264
|
+
rows,
|
|
265
|
+
totals: [{ field: "amount", label: "Grand total", value: 1234 }],
|
|
266
|
+
})}
|
|
267
|
+
/>,
|
|
268
|
+
);
|
|
269
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
270
|
+
const totals = desktop.getByTestId("lines-totals");
|
|
271
|
+
expect(within(totals).getByText("Grand total")).toBeTruthy();
|
|
272
|
+
// Money total formats via Intl.NumberFormat (EUR) — assert the digits
|
|
273
|
+
// it must contain rather than pinning the exact locale/space glyphs.
|
|
274
|
+
expect(totals.textContent).toContain("12");
|
|
275
|
+
expect(totals.textContent).toContain("34");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a plain number total renders as a locale-formatted number", () => {
|
|
279
|
+
const rows = [{ description: "A", quantity: 7, amount: 100 }];
|
|
280
|
+
renderWithLocale(
|
|
281
|
+
<EmbeddedListInput
|
|
282
|
+
{...baseProps({
|
|
283
|
+
rows,
|
|
284
|
+
columns: [
|
|
285
|
+
{
|
|
286
|
+
field: "description",
|
|
287
|
+
label: "Description",
|
|
288
|
+
type: "text",
|
|
289
|
+
required: true,
|
|
290
|
+
derived: false,
|
|
291
|
+
},
|
|
292
|
+
{ field: "quantity", label: "Qty", type: "number", required: true, derived: false },
|
|
293
|
+
],
|
|
294
|
+
totals: [{ field: "quantity", label: "Total qty", value: 7 }],
|
|
295
|
+
})}
|
|
296
|
+
/>,
|
|
297
|
+
);
|
|
298
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
299
|
+
const totals = desktop.getByTestId("lines-totals");
|
|
300
|
+
expect(within(totals).getByText("7")).toBeTruthy();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("EmbeddedListInput — tab-to-add-row", () => {
|
|
305
|
+
test("Tab on the last cell of the last row (no maxItems limit) fires onAddRow and prevents default", () => {
|
|
306
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
307
|
+
const onAddRow = mock(() => {});
|
|
308
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow })} />);
|
|
309
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
310
|
+
const lastCell = desktop.getByTestId("lines-cell-0-amount");
|
|
311
|
+
const input = lastCell.querySelector("input");
|
|
312
|
+
if (input === null) throw new Error("expected an <input> inside the last cell");
|
|
313
|
+
|
|
314
|
+
// fireEvent returns false when the event's default was prevented —
|
|
315
|
+
// same return-value convention as native dispatchEvent.
|
|
316
|
+
const notPrevented = fireEvent.keyDown(input, { key: "Tab", code: "Tab" });
|
|
317
|
+
expect(onAddRow).toHaveBeenCalledTimes(1);
|
|
318
|
+
expect(notPrevented).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("Tab does not fire onAddRow once maxItems is reached", () => {
|
|
322
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
323
|
+
const onAddRow = mock(() => {});
|
|
324
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow, maxItems: 1 })} />);
|
|
325
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
326
|
+
const lastCell = desktop.getByTestId("lines-cell-0-amount");
|
|
327
|
+
const input = lastCell.querySelector("input");
|
|
328
|
+
if (input === null) throw new Error("expected an <input> inside the last cell");
|
|
329
|
+
|
|
330
|
+
fireEvent.keyDown(input, { key: "Tab", code: "Tab" });
|
|
331
|
+
expect(onAddRow).not.toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("EmbeddedListInput — paste", () => {
|
|
336
|
+
test("a two-row, two-column tab-separated paste fires onPasteCells with the parsed grid", () => {
|
|
337
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
338
|
+
const onPasteCells = mock(
|
|
339
|
+
(_r: number, _c: number, _grid: readonly (readonly string[])[]) => {},
|
|
340
|
+
);
|
|
341
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onPasteCells })} />);
|
|
342
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
343
|
+
const cell = desktop.getByTestId("lines-cell-0-description");
|
|
344
|
+
const input = cell.querySelector("input");
|
|
345
|
+
if (input === null) throw new Error("expected an <input> inside the cell");
|
|
346
|
+
|
|
347
|
+
const clipboardData = {
|
|
348
|
+
getData: (format: string) => (format === "text" ? "Widget\t5\nGadget\t9" : ""),
|
|
349
|
+
};
|
|
350
|
+
fireEvent.paste(input, { clipboardData });
|
|
351
|
+
|
|
352
|
+
expect(onPasteCells).toHaveBeenCalledTimes(1);
|
|
353
|
+
const [rowIndex, columnIndex, grid] = onPasteCells.mock.calls[0] as [
|
|
354
|
+
number,
|
|
355
|
+
number,
|
|
356
|
+
readonly (readonly string[])[],
|
|
357
|
+
];
|
|
358
|
+
expect(rowIndex).toBe(0);
|
|
359
|
+
expect(columnIndex).toBe(0);
|
|
360
|
+
expect(grid).toEqual([
|
|
361
|
+
["Widget", "5"],
|
|
362
|
+
["Gadget", "9"],
|
|
363
|
+
]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("a single-value paste does not call onPasteCells", () => {
|
|
367
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
368
|
+
const onPasteCells = mock(
|
|
369
|
+
(_r: number, _c: number, _grid: readonly (readonly string[])[]) => {},
|
|
370
|
+
);
|
|
371
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onPasteCells })} />);
|
|
372
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
373
|
+
const cell = desktop.getByTestId("lines-cell-0-description");
|
|
374
|
+
const input = cell.querySelector("input");
|
|
375
|
+
if (input === null) throw new Error("expected an <input> inside the cell");
|
|
376
|
+
|
|
377
|
+
const clipboardData = { getData: (format: string) => (format === "text" ? "Solo" : "") };
|
|
378
|
+
fireEvent.paste(input, { clipboardData });
|
|
379
|
+
expect(onPasteCells).not.toHaveBeenCalled();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe("EmbeddedListInput — Enter-to-add-row (#1839)", () => {
|
|
384
|
+
test("Enter on the last cell of the last row fires onAddRow and prevents default, same as Tab", () => {
|
|
385
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
386
|
+
const onAddRow = mock(() => {});
|
|
387
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow })} />);
|
|
388
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
389
|
+
const lastCell = desktop.getByTestId("lines-cell-0-amount");
|
|
390
|
+
const input = lastCell.querySelector("input");
|
|
391
|
+
if (input === null) throw new Error("expected an <input> inside the last cell");
|
|
392
|
+
|
|
393
|
+
const notPrevented = fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
394
|
+
expect(onAddRow).toHaveBeenCalledTimes(1);
|
|
395
|
+
// false = event.preventDefault() was called — same convention the
|
|
396
|
+
// existing Tab test relies on. A form wrapping this field must not see
|
|
397
|
+
// Enter as a submit trigger.
|
|
398
|
+
expect(notPrevented).toBe(false);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("Enter does not fire onAddRow once maxItems is reached", () => {
|
|
402
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
403
|
+
const onAddRow = mock(() => {});
|
|
404
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow, maxItems: 1 })} />);
|
|
405
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
406
|
+
const lastCell = desktop.getByTestId("lines-cell-0-amount");
|
|
407
|
+
const input = lastCell.querySelector("input");
|
|
408
|
+
if (input === null) throw new Error("expected an <input> inside the last cell");
|
|
409
|
+
|
|
410
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
411
|
+
expect(onAddRow).not.toHaveBeenCalled();
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe("EmbeddedListInput — auto-focus into wrapped cell types (#1839)", () => {
|
|
416
|
+
test("a date first-column focuses the inner DateField input, not the wrapper div", () => {
|
|
417
|
+
const columns: readonly EmbeddedListColumn[] = [
|
|
418
|
+
{ field: "due", label: "Due", type: "date", required: false, derived: false },
|
|
419
|
+
{ field: "desc", label: "Desc", type: "text", required: false, derived: false },
|
|
420
|
+
];
|
|
421
|
+
renderWithLocale(
|
|
422
|
+
<ControlledFocusHarness columns={columns} initialRows={[{ due: "", desc: "x" }]} />,
|
|
423
|
+
);
|
|
424
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
425
|
+
const lastInput = desktop.getByTestId("lines-cell-0-desc").querySelector("input");
|
|
426
|
+
if (lastInput === null) throw new Error("expected an <input> in the last cell");
|
|
427
|
+
fireEvent.keyDown(lastInput, { key: "Tab", code: "Tab" });
|
|
428
|
+
|
|
429
|
+
const newDueCell = desktop.getByTestId("lines-cell-1-due");
|
|
430
|
+
expect(newDueCell.tagName).not.toBe("INPUT");
|
|
431
|
+
const focusable = newDueCell.querySelector("input");
|
|
432
|
+
expect(focusable).not.toBeNull();
|
|
433
|
+
expect(document.activeElement).toBe(focusable);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("a timestamp first-column focuses the inner TimestampInput date field, not the wrapper div (#1839)", () => {
|
|
437
|
+
const columns: readonly EmbeddedListColumn[] = [
|
|
438
|
+
{ field: "loggedAt", label: "Logged at", type: "timestamp", required: false, derived: false },
|
|
439
|
+
{ field: "desc", label: "Desc", type: "text", required: false, derived: false },
|
|
440
|
+
];
|
|
441
|
+
renderWithLocale(
|
|
442
|
+
<ControlledFocusHarness columns={columns} initialRows={[{ loggedAt: "", desc: "x" }]} />,
|
|
443
|
+
);
|
|
444
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
445
|
+
const lastInput = desktop.getByTestId("lines-cell-0-desc").querySelector("input");
|
|
446
|
+
if (lastInput === null) throw new Error("expected an <input> in the last cell");
|
|
447
|
+
fireEvent.keyDown(lastInput, { key: "Tab", code: "Tab" });
|
|
448
|
+
|
|
449
|
+
const newLoggedAtCell = desktop.getByTestId("lines-cell-1-loggedAt");
|
|
450
|
+
expect(newLoggedAtCell.tagName).not.toBe("INPUT");
|
|
451
|
+
const focusable = newLoggedAtCell.querySelector("input");
|
|
452
|
+
expect(focusable).not.toBeNull();
|
|
453
|
+
expect(document.activeElement).toBe(focusable);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("a money first-column focuses the inner MoneyInput input, not the wrapper div", () => {
|
|
457
|
+
const columns: readonly EmbeddedListColumn[] = [
|
|
458
|
+
{ field: "amount", label: "Amount", type: "money", required: false, derived: false },
|
|
459
|
+
{ field: "desc", label: "Desc", type: "text", required: false, derived: false },
|
|
460
|
+
];
|
|
461
|
+
renderWithLocale(
|
|
462
|
+
<ControlledFocusHarness columns={columns} initialRows={[{ amount: 0, desc: "x" }]} />,
|
|
463
|
+
);
|
|
464
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
465
|
+
const lastInput = desktop.getByTestId("lines-cell-0-desc").querySelector("input");
|
|
466
|
+
if (lastInput === null) throw new Error("expected an <input> in the last cell");
|
|
467
|
+
fireEvent.keyDown(lastInput, { key: "Tab", code: "Tab" });
|
|
468
|
+
|
|
469
|
+
const newAmountCell = desktop.getByTestId("lines-cell-1-amount");
|
|
470
|
+
const focusable = newAmountCell.querySelector("input");
|
|
471
|
+
expect(focusable).not.toBeNull();
|
|
472
|
+
expect(document.activeElement).toBe(focusable);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("a select first-column focuses the combobox trigger button, not the hidden name-input or the wrapper div", () => {
|
|
476
|
+
const columns: readonly EmbeddedListColumn[] = [
|
|
477
|
+
{
|
|
478
|
+
field: "unit",
|
|
479
|
+
label: "Unit",
|
|
480
|
+
type: "select",
|
|
481
|
+
required: false,
|
|
482
|
+
derived: false,
|
|
483
|
+
options: ["hour", "day"],
|
|
484
|
+
},
|
|
485
|
+
{ field: "desc", label: "Desc", type: "text", required: false, derived: false },
|
|
486
|
+
];
|
|
487
|
+
renderWithLocale(
|
|
488
|
+
<ControlledFocusHarness columns={columns} initialRows={[{ unit: "hour", desc: "x" }]} />,
|
|
489
|
+
);
|
|
490
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
491
|
+
const lastInput = desktop.getByTestId("lines-cell-0-desc").querySelector("input");
|
|
492
|
+
if (lastInput === null) throw new Error("expected an <input> in the last cell");
|
|
493
|
+
fireEvent.keyDown(lastInput, { key: "Tab", code: "Tab" });
|
|
494
|
+
|
|
495
|
+
const newUnitCell = desktop.getByTestId("lines-cell-1-unit");
|
|
496
|
+
const hiddenInput = newUnitCell.querySelector('input[type="hidden"]');
|
|
497
|
+
const trigger = newUnitCell.querySelector("button");
|
|
498
|
+
expect(hiddenInput).not.toBeNull();
|
|
499
|
+
expect(trigger).not.toBeNull();
|
|
500
|
+
// Must not land on the hidden input (a no-op focus target) — must be
|
|
501
|
+
// the actual combobox trigger the user can operate.
|
|
502
|
+
expect(document.activeElement).not.toBe(hiddenInput);
|
|
503
|
+
expect(document.activeElement).toBe(trigger);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("a reference first-column focuses the combobox trigger button, not the hidden name-input or the wrapper div", () => {
|
|
507
|
+
const columns: readonly EmbeddedListColumn[] = [
|
|
508
|
+
{
|
|
509
|
+
field: "product",
|
|
510
|
+
label: "Product",
|
|
511
|
+
type: "reference",
|
|
512
|
+
required: false,
|
|
513
|
+
derived: false,
|
|
514
|
+
referenceOptions: [{ value: "p1", label: "Widget" }],
|
|
515
|
+
},
|
|
516
|
+
{ field: "desc", label: "Desc", type: "text", required: false, derived: false },
|
|
517
|
+
];
|
|
518
|
+
renderWithLocale(
|
|
519
|
+
<ControlledFocusHarness columns={columns} initialRows={[{ product: "p1", desc: "x" }]} />,
|
|
520
|
+
);
|
|
521
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
522
|
+
const lastInput = desktop.getByTestId("lines-cell-0-desc").querySelector("input");
|
|
523
|
+
if (lastInput === null) throw new Error("expected an <input> in the last cell");
|
|
524
|
+
fireEvent.keyDown(lastInput, { key: "Tab", code: "Tab" });
|
|
525
|
+
|
|
526
|
+
const newProductCell = desktop.getByTestId("lines-cell-1-product");
|
|
527
|
+
const hiddenInput = newProductCell.querySelector('input[type="hidden"]');
|
|
528
|
+
const trigger = newProductCell.querySelector("button");
|
|
529
|
+
expect(hiddenInput).not.toBeNull();
|
|
530
|
+
expect(trigger).not.toBeNull();
|
|
531
|
+
expect(document.activeElement).not.toBe(hiddenInput);
|
|
532
|
+
expect(document.activeElement).toBe(trigger);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
describe("EmbeddedListInput — currency (#1839)", () => {
|
|
537
|
+
const currencyColumns: readonly EmbeddedListColumn[] = [
|
|
538
|
+
{ field: "description", label: "Description", type: "text", required: true, derived: false },
|
|
539
|
+
{ field: "amount", label: "Amount", type: "money", required: false, derived: false },
|
|
540
|
+
];
|
|
541
|
+
|
|
542
|
+
test("currency prop formats the totals row in that currency instead of the EUR default", () => {
|
|
543
|
+
const rows = [{ description: "A", amount: 150000 }];
|
|
544
|
+
renderWithLocale(
|
|
545
|
+
<EmbeddedListInput
|
|
546
|
+
{...baseProps({
|
|
547
|
+
columns: currencyColumns,
|
|
548
|
+
rows,
|
|
549
|
+
currency: "USD",
|
|
550
|
+
totals: [{ field: "amount", label: "Total", value: 150000 }],
|
|
551
|
+
})}
|
|
552
|
+
/>,
|
|
553
|
+
);
|
|
554
|
+
const totals = within(screen.getByTestId("lines-desktop")).getByTestId("lines-totals");
|
|
555
|
+
expect(totals.textContent).toContain("$");
|
|
556
|
+
expect(totals.textContent).not.toContain("€");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("currency prop is passed through to money-cell MoneyInput", () => {
|
|
560
|
+
const rows = [{ description: "A", amount: 150000 }];
|
|
561
|
+
renderWithLocale(
|
|
562
|
+
<EmbeddedListInput {...baseProps({ columns: currencyColumns, rows, currency: "USD" })} />,
|
|
563
|
+
);
|
|
564
|
+
const cell = within(screen.getByTestId("lines-desktop")).getByTestId("lines-cell-0-amount");
|
|
565
|
+
const input = cell.querySelector("input") as HTMLInputElement;
|
|
566
|
+
expect(input.value).toContain("$");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("without a currency prop, totals row still formats as EUR (backward-compatible default)", () => {
|
|
570
|
+
const rows = [{ description: "A", amount: 150000 }];
|
|
571
|
+
renderWithLocale(
|
|
572
|
+
<EmbeddedListInput
|
|
573
|
+
{...baseProps({
|
|
574
|
+
columns: currencyColumns,
|
|
575
|
+
rows,
|
|
576
|
+
totals: [{ field: "amount", label: "Total", value: 150000 }],
|
|
577
|
+
})}
|
|
578
|
+
/>,
|
|
579
|
+
);
|
|
580
|
+
const totals = within(screen.getByTestId("lines-desktop")).getByTestId("lines-totals");
|
|
581
|
+
expect(totals.textContent).toContain("€");
|
|
582
|
+
});
|
|
583
|
+
});
|