@cosmicdrift/kumiko-renderer 0.288.0 → 0.290.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__/merge-search-params-into-initial.test.ts +18 -0
- package/src/app/__tests__/form-schema.test.ts +15 -0
- package/src/app/layout-fields.ts +6 -2
- package/src/app/projection-detail-shim.ts +2 -12
- package/src/changes.json +6 -0
- package/src/components/__tests__/embedded-list-field.test.tsx +65 -46
- package/src/i18n-defaults.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.290.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.290.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.290.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.290.0",
|
|
21
21
|
"react": "^19.2.6",
|
|
22
22
|
"temporal-polyfill": "^0.3.2",
|
|
23
23
|
"zod": "^4.4.3"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"jsdom": "^29.1.1",
|
|
30
30
|
"react-dom": "^19.2.6",
|
|
31
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.290.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
3
|
import { mergeSearchParamsIntoInitial } from "../app/kumiko-screen";
|
|
4
|
+
import { layoutFieldNames } from "../app/layout-fields";
|
|
3
5
|
|
|
4
6
|
type FieldDef = {
|
|
5
7
|
type?: string;
|
|
@@ -188,6 +190,22 @@ describe("mergeSearchParamsIntoInitial — coercion (every field URL-prefillable
|
|
|
188
190
|
expect(result["ownerId"]).toBe("");
|
|
189
191
|
});
|
|
190
192
|
|
|
193
|
+
test("renderableFields from a groups-only layout: searchParam on a group field is kept", () => {
|
|
194
|
+
const screen: EntityEditScreenDefinition = {
|
|
195
|
+
id: "s",
|
|
196
|
+
type: "entityEdit",
|
|
197
|
+
entity: "e",
|
|
198
|
+
layout: { sections: [{ fields: [], groups: [{ title: "g", fields: ["status"] }] }] },
|
|
199
|
+
};
|
|
200
|
+
const fields: Record<string, FieldDef> = { status: { type: "text", default: "draft" } };
|
|
201
|
+
const result = mergeWithAllFieldsUrlPrefillable(
|
|
202
|
+
fields,
|
|
203
|
+
{ status: "approved" },
|
|
204
|
+
layoutFieldNames(screen),
|
|
205
|
+
);
|
|
206
|
+
expect(result["status"]).toBe("approved");
|
|
207
|
+
});
|
|
208
|
+
|
|
191
209
|
test("no renderableFields set given (undefined): behaves as before, all fields eligible", () => {
|
|
192
210
|
const fields: Record<string, FieldDef> = { ownerId: { type: "text" } };
|
|
193
211
|
const result = mergeWithAllFieldsUrlPrefillable(fields, { ownerId: "user-123" });
|
|
@@ -41,6 +41,21 @@ describe("buildFormSchema", () => {
|
|
|
41
41
|
}
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
test("required field inside a groups-only section → presence issue", () => {
|
|
45
|
+
const entity = entityWith({ name: { type: "text", required: true } });
|
|
46
|
+
const screen: EntityEditScreenDefinition = {
|
|
47
|
+
id: "s",
|
|
48
|
+
type: "entityEdit",
|
|
49
|
+
entity: "e",
|
|
50
|
+
layout: { sections: [{ fields: [], groups: [{ title: "g", fields: ["name"] }] }] },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const result = buildFormSchema(entity, screen).safeParse({ name: "" });
|
|
54
|
+
expect(result.success).toBe(false);
|
|
55
|
+
if (result.success) return;
|
|
56
|
+
expect(result.error.issues[0]?.path).toEqual(["name"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
44
59
|
test("required field missing → issue carries the required-field i18nKey override", () => {
|
|
45
60
|
const entity = entityWith({ name: { type: "text", required: true } });
|
|
46
61
|
const screen = screenWith(["name"]);
|
package/src/app/layout-fields.ts
CHANGED
|
@@ -2,7 +2,11 @@ import type {
|
|
|
2
2
|
EditFieldSpec,
|
|
3
3
|
EntityEditScreenDefinition,
|
|
4
4
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
isFieldsEditSection,
|
|
7
|
+
normalizeEditField,
|
|
8
|
+
sectionFieldSpecs,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
10
|
|
|
7
11
|
// Normalized field specs actually rendered by the screen's layout, extension
|
|
8
12
|
// (and relatedList) sections skipped. Both this and `layoutFieldNames` key
|
|
@@ -15,7 +19,7 @@ export function layoutEditFields(
|
|
|
15
19
|
const specs: Exclude<EditFieldSpec, string>[] = [];
|
|
16
20
|
for (const section of screen.layout.sections) {
|
|
17
21
|
if (!isFieldsEditSection(section)) continue;
|
|
18
|
-
for (const spec of section
|
|
22
|
+
for (const spec of sectionFieldSpecs(section)) {
|
|
19
23
|
specs.push(normalizeEditField(spec));
|
|
20
24
|
}
|
|
21
25
|
}
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
|
|
23
23
|
import type {
|
|
24
24
|
EditFieldSpec,
|
|
25
|
-
EditFieldsSection,
|
|
26
25
|
EditLayout,
|
|
27
26
|
EntityDefinition,
|
|
28
27
|
EntityEditScreenDefinition,
|
|
@@ -32,18 +31,9 @@ import {
|
|
|
32
31
|
isFieldsEditSection,
|
|
33
32
|
normalizeEditField,
|
|
34
33
|
PROJECTION_DETAIL_ENTITY as PROJECTION_DETAIL_PSEUDO_ENTITY,
|
|
34
|
+
sectionFieldSpecs,
|
|
35
35
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
36
36
|
|
|
37
|
-
// The boot-validator treats `fields`/`groups` as mutually exclusive per
|
|
38
|
-
// section, but this reads both anyway: an extra name the current section
|
|
39
|
-
// shape can never carry is harmless, while a name computeEditViewModel does
|
|
40
|
-
// reference and this shim omitted throws downstream ("references unknown
|
|
41
|
-
// field ... on entity"). Union is strictly safer than picking one branch.
|
|
42
|
-
function allFieldSpecs(section: EditFieldsSection): readonly EditFieldSpec[] {
|
|
43
|
-
const grouped = section.groups?.flatMap((group) => group.fields) ?? [];
|
|
44
|
-
return [...section.fields, ...grouped];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
37
|
/** Minimale EntityDefinition aus den Layout-Feldern: jedes Feld ein Text-
|
|
48
38
|
* Feld — computeEditViewModel liest nur `fields[<f>].type`, Text reicht für
|
|
49
39
|
* eine reine Anzeige (kein Select/Number-spezifisches Rendering nötig). */
|
|
@@ -52,7 +42,7 @@ export function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefi
|
|
|
52
42
|
for (const section of layout.sections) {
|
|
53
43
|
// relatedList and extension sections carry no `fields` to synthesize.
|
|
54
44
|
if (!isFieldsEditSection(section)) continue;
|
|
55
|
-
for (const spec of
|
|
45
|
+
for (const spec of sectionFieldSpecs(section)) {
|
|
56
46
|
fields[normalizeEditField(spec).field] = { type: "text" };
|
|
57
47
|
}
|
|
58
48
|
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.290.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Layout field readers see groups[].fields",
|
|
6
|
+
"detail": "layoutEditFields flattens section.groups, so required fields inside a groups-only section are presence-validated by buildFormSchema and survive the renderableFields gate for search-param prefills."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.283.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -22,7 +22,18 @@ import { RenderField, type RenderFieldProps } from "../render-field";
|
|
|
22
22
|
|
|
23
23
|
let captured: EmbeddedListInputProps | undefined;
|
|
24
24
|
const captureEmbeddedListInput: ComponentType<EmbeddedListInputProps> = (props) => {
|
|
25
|
-
captured =
|
|
25
|
+
captured = {
|
|
26
|
+
...props,
|
|
27
|
+
onCellChange: (...args) => act(() => props.onCellChange(...args)),
|
|
28
|
+
onAddRow: () => act(() => props.onAddRow()),
|
|
29
|
+
onRemoveRow: (rowIndex) => act(() => props.onRemoveRow(rowIndex)),
|
|
30
|
+
onDuplicateRow: (rowIndex) => act(() => props.onDuplicateRow(rowIndex)),
|
|
31
|
+
onMoveRow: (fromIndex, toIndex) => act(() => props.onMoveRow(fromIndex, toIndex)),
|
|
32
|
+
...(props.onPasteCells !== undefined && {
|
|
33
|
+
onPasteCells: (...args: Parameters<NonNullable<EmbeddedListInputProps["onPasteCells"]>>) =>
|
|
34
|
+
act(() => props.onPasteCells?.(...args)),
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
26
37
|
return null;
|
|
27
38
|
};
|
|
28
39
|
const noop = (): ReactNode => null;
|
|
@@ -112,12 +123,12 @@ function invoiceLinesField(overrides: Partial<EditFieldViewModel> = {}): EditFie
|
|
|
112
123
|
};
|
|
113
124
|
}
|
|
114
125
|
|
|
115
|
-
function renderEmbeddedListField(
|
|
126
|
+
async function renderEmbeddedListField(
|
|
116
127
|
field: EditFieldViewModel,
|
|
117
128
|
onChange: (v: unknown) => void,
|
|
118
129
|
allIssues: Readonly<Record<string, readonly FieldIssue[]>> = {},
|
|
119
130
|
productRows: readonly ProductRow[] = [],
|
|
120
|
-
): void {
|
|
131
|
+
): Promise<void> {
|
|
121
132
|
captured = undefined;
|
|
122
133
|
render(
|
|
123
134
|
<LocaleProvider
|
|
@@ -137,13 +148,14 @@ function renderEmbeddedListField(
|
|
|
137
148
|
</DispatcherProvider>
|
|
138
149
|
</LocaleProvider>,
|
|
139
150
|
);
|
|
151
|
+
await act(async () => {});
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
describe("EmbeddedListField — cell change recomputes derived", () => {
|
|
143
|
-
test("changing quantity updates the cell and recomputes amount in the same row", () => {
|
|
155
|
+
test("changing quantity updates the cell and recomputes amount in the same row", async () => {
|
|
144
156
|
const rows = [{ product: "p1", unit: "pcs", quantity: 2, unitPrice: 500, amount: 1000 }];
|
|
145
157
|
let lastValue: unknown;
|
|
146
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
158
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
147
159
|
lastValue = v;
|
|
148
160
|
});
|
|
149
161
|
captured?.onCellChange(0, "quantity", 4);
|
|
@@ -152,10 +164,10 @@ describe("EmbeddedListField — cell change recomputes derived", () => {
|
|
|
152
164
|
]);
|
|
153
165
|
});
|
|
154
166
|
|
|
155
|
-
test("a fractional product on the money-typed amount cell shows the rounded live preview, not the raw fraction", () => {
|
|
167
|
+
test("a fractional product on the money-typed amount cell shows the rounded live preview, not the raw fraction", async () => {
|
|
156
168
|
const rows = [{ product: "p1", unit: "pcs", quantity: 2, unitPrice: 923, amount: 1846 }];
|
|
157
169
|
let lastValue: unknown;
|
|
158
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
170
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
159
171
|
lastValue = v;
|
|
160
172
|
});
|
|
161
173
|
// 2.5 * 923 = 2307.5 — a fractional minor-unit amount the money target
|
|
@@ -177,7 +189,7 @@ describe("EmbeddedListField — cell change recomputes derived", () => {
|
|
|
177
189
|
// frozen `source` snapshot, same as the server. Two field configs with
|
|
178
190
|
// "net"/"gross" declared in opposite key order must therefore produce the
|
|
179
191
|
// identical, order-independent result.
|
|
180
|
-
test("chained derived cells (gross depends on net) recompute the same value regardless of the derived-map's key order", () => {
|
|
192
|
+
test("chained derived cells (gross depends on net) recompute the same value regardless of the derived-map's key order", async () => {
|
|
181
193
|
const cells: EditFieldViewModel["embeddedListCells"] = [
|
|
182
194
|
{ field: "qty", label: "Qty", type: "number", required: true },
|
|
183
195
|
{ field: "price", label: "Price", type: "number", required: true },
|
|
@@ -194,7 +206,7 @@ describe("EmbeddedListField — cell change recomputes derived", () => {
|
|
|
194
206
|
const expected = { qty: 4, price: 3, tax: 1, net: 12, gross: 7 };
|
|
195
207
|
|
|
196
208
|
let netFirstResult: unknown;
|
|
197
|
-
renderEmbeddedListField(
|
|
209
|
+
await renderEmbeddedListField(
|
|
198
210
|
{
|
|
199
211
|
...invoiceLinesField({ value: rows }),
|
|
200
212
|
embeddedListCells: cells,
|
|
@@ -210,7 +222,7 @@ describe("EmbeddedListField — cell change recomputes derived", () => {
|
|
|
210
222
|
captured?.onCellChange(0, "qty", 4);
|
|
211
223
|
|
|
212
224
|
let grossFirstResult: unknown;
|
|
213
|
-
renderEmbeddedListField(
|
|
225
|
+
await renderEmbeddedListField(
|
|
214
226
|
{
|
|
215
227
|
...invoiceLinesField({ value: rows }),
|
|
216
228
|
embeddedListCells: cells,
|
|
@@ -236,45 +248,45 @@ describe("EmbeddedListField — row operations", () => {
|
|
|
236
248
|
{ product: "p2", unit: "hours", quantity: 2, unitPrice: 200, amount: 400 },
|
|
237
249
|
];
|
|
238
250
|
|
|
239
|
-
test("onAddRow appends an empty row with derived recomputed", () => {
|
|
251
|
+
test("onAddRow appends an empty row with derived recomputed", async () => {
|
|
240
252
|
let lastValue: unknown;
|
|
241
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
253
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
242
254
|
lastValue = v;
|
|
243
255
|
});
|
|
244
256
|
captured?.onAddRow();
|
|
245
257
|
expect(lastValue).toEqual([...rows, { amount: undefined }]);
|
|
246
258
|
});
|
|
247
259
|
|
|
248
|
-
test("onRemoveRow removes exactly the targeted row", () => {
|
|
260
|
+
test("onRemoveRow removes exactly the targeted row", async () => {
|
|
249
261
|
let lastValue: unknown;
|
|
250
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
262
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
251
263
|
lastValue = v;
|
|
252
264
|
});
|
|
253
265
|
captured?.onRemoveRow(0);
|
|
254
266
|
expect(lastValue).toEqual([rows[1]]);
|
|
255
267
|
});
|
|
256
268
|
|
|
257
|
-
test("onDuplicateRow inserts a copy right after the source row", () => {
|
|
269
|
+
test("onDuplicateRow inserts a copy right after the source row", async () => {
|
|
258
270
|
let lastValue: unknown;
|
|
259
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
271
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
260
272
|
lastValue = v;
|
|
261
273
|
});
|
|
262
274
|
captured?.onDuplicateRow(0);
|
|
263
275
|
expect(lastValue).toEqual([rows[0], rows[0], rows[1]]);
|
|
264
276
|
});
|
|
265
277
|
|
|
266
|
-
test("onDuplicateRow is a no-op for an out-of-range index", () => {
|
|
278
|
+
test("onDuplicateRow is a no-op for an out-of-range index", async () => {
|
|
267
279
|
let called = false;
|
|
268
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
|
|
280
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
|
|
269
281
|
called = true;
|
|
270
282
|
});
|
|
271
283
|
captured?.onDuplicateRow(99);
|
|
272
284
|
expect(called).toBe(false);
|
|
273
285
|
});
|
|
274
286
|
|
|
275
|
-
test("onMoveRow moves an element from one index to another, immutably", () => {
|
|
287
|
+
test("onMoveRow moves an element from one index to another, immutably", async () => {
|
|
276
288
|
let lastValue: unknown;
|
|
277
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
289
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
278
290
|
lastValue = v;
|
|
279
291
|
});
|
|
280
292
|
captured?.onMoveRow(1, 0);
|
|
@@ -286,9 +298,9 @@ describe("EmbeddedListField — row operations", () => {
|
|
|
286
298
|
]);
|
|
287
299
|
});
|
|
288
300
|
|
|
289
|
-
test("onMoveRow is a no-op for an out-of-range target index", () => {
|
|
301
|
+
test("onMoveRow is a no-op for an out-of-range target index", async () => {
|
|
290
302
|
let called = false;
|
|
291
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
|
|
303
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
|
|
292
304
|
called = true;
|
|
293
305
|
});
|
|
294
306
|
captured?.onMoveRow(0, 5);
|
|
@@ -297,10 +309,10 @@ describe("EmbeddedListField — row operations", () => {
|
|
|
297
309
|
});
|
|
298
310
|
|
|
299
311
|
describe("EmbeddedListField — paste coercion", () => {
|
|
300
|
-
test("pastes number/money/select cells with correct coercion per column type", () => {
|
|
312
|
+
test("pastes number/money/select cells with correct coercion per column type", async () => {
|
|
301
313
|
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
302
314
|
let lastValue: unknown;
|
|
303
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
315
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
304
316
|
lastValue = v;
|
|
305
317
|
});
|
|
306
318
|
// Columns in order: product, unit, quantity, unitPrice, amount.
|
|
@@ -315,10 +327,10 @@ describe("EmbeddedListField — paste coercion", () => {
|
|
|
315
327
|
// the typed-in path's currencyDecimals-based scaling for any non-2-decimal
|
|
316
328
|
// currency — JPY (0 decimals) pasted "1234" would have landed on 123400
|
|
317
329
|
// (100x too large) instead of 1234.
|
|
318
|
-
test("pastes a money cell for a zero-decimal currency (JPY) without the paste-path ×100 bug", () => {
|
|
330
|
+
test("pastes a money cell for a zero-decimal currency (JPY) without the paste-path ×100 bug", async () => {
|
|
319
331
|
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 0, amount: 0 }];
|
|
320
332
|
let lastValue: unknown;
|
|
321
|
-
renderEmbeddedListField(
|
|
333
|
+
await renderEmbeddedListField(
|
|
322
334
|
invoiceLinesField({ value: rows, embeddedListCurrency: "JPY" }),
|
|
323
335
|
(v) => {
|
|
324
336
|
lastValue = v;
|
|
@@ -331,12 +343,15 @@ describe("EmbeddedListField — paste coercion", () => {
|
|
|
331
343
|
expect(result[0]?.["unitPrice"]).toBe(1234);
|
|
332
344
|
});
|
|
333
345
|
|
|
334
|
-
test("paste beyond the current rows appends new rows but never past maxItems", () => {
|
|
346
|
+
test("paste beyond the current rows appends new rows but never past maxItems", async () => {
|
|
335
347
|
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
336
348
|
let lastValue: unknown;
|
|
337
|
-
renderEmbeddedListField(
|
|
338
|
-
|
|
339
|
-
|
|
349
|
+
await renderEmbeddedListField(
|
|
350
|
+
invoiceLinesField({ value: rows, embeddedListMaxItems: 2 }),
|
|
351
|
+
(v) => {
|
|
352
|
+
lastValue = v;
|
|
353
|
+
},
|
|
354
|
+
);
|
|
340
355
|
captured?.onPasteCells?.(0, 2, [
|
|
341
356
|
["1", "100"],
|
|
342
357
|
["2", "200"],
|
|
@@ -348,7 +363,10 @@ describe("EmbeddedListField — paste coercion", () => {
|
|
|
348
363
|
|
|
349
364
|
test("paste beyond maxItems surfaces a listIssue with the dropped-row count", async () => {
|
|
350
365
|
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
351
|
-
renderEmbeddedListField(
|
|
366
|
+
await renderEmbeddedListField(
|
|
367
|
+
invoiceLinesField({ value: rows, embeddedListMaxItems: 2 }),
|
|
368
|
+
() => {},
|
|
369
|
+
);
|
|
352
370
|
act(() => {
|
|
353
371
|
captured?.onPasteCells?.(0, 2, [
|
|
354
372
|
["1", "100"],
|
|
@@ -371,7 +389,7 @@ describe("EmbeddedListField — paste coercion", () => {
|
|
|
371
389
|
test("a select paste value with no matching option leaves the cell unchanged and surfaces a listIssue", async () => {
|
|
372
390
|
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
373
391
|
let lastValue: unknown;
|
|
374
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
392
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
375
393
|
lastValue = v;
|
|
376
394
|
});
|
|
377
395
|
// Columns in order: product, unit, quantity, unitPrice, amount. Column
|
|
@@ -396,10 +414,10 @@ describe("EmbeddedListField — paste coercion", () => {
|
|
|
396
414
|
// kumiko-framework#1838: a naive `replace(",", ".")` on a DE-locale paste
|
|
397
415
|
// with a thousands separator turned "1.234,56" into "1.234.56" → NaN →
|
|
398
416
|
// the cell silently cleared instead of keeping the pasted amount.
|
|
399
|
-
test("pastes a DE-locale money value with a thousands separator (1.234,56)", () => {
|
|
417
|
+
test("pastes a DE-locale money value with a thousands separator (1.234,56)", async () => {
|
|
400
418
|
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
401
419
|
let lastValue: unknown;
|
|
402
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
420
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
403
421
|
lastValue = v;
|
|
404
422
|
});
|
|
405
423
|
// Column index 3 = "unitPrice".
|
|
@@ -408,10 +426,10 @@ describe("EmbeddedListField — paste coercion", () => {
|
|
|
408
426
|
expect(result[0]?.["unitPrice"]).toBe(123456);
|
|
409
427
|
});
|
|
410
428
|
|
|
411
|
-
test("pastes an en-US-locale money value with a thousands separator (1,234.56)", () => {
|
|
429
|
+
test("pastes an en-US-locale money value with a thousands separator (1,234.56)", async () => {
|
|
412
430
|
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
413
431
|
let lastValue: unknown;
|
|
414
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
432
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
415
433
|
lastValue = v;
|
|
416
434
|
});
|
|
417
435
|
captured?.onPasteCells?.(0, 3, [["1,234.56"]]);
|
|
@@ -422,7 +440,7 @@ describe("EmbeddedListField — paste coercion", () => {
|
|
|
422
440
|
test("an unparseable money paste leaves the cell unchanged and surfaces a listIssue instead of clearing it", async () => {
|
|
423
441
|
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
424
442
|
let lastValue: unknown;
|
|
425
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
443
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
426
444
|
lastValue = v;
|
|
427
445
|
});
|
|
428
446
|
act(() => {
|
|
@@ -445,7 +463,7 @@ describe("EmbeddedListField — paste coercion", () => {
|
|
|
445
463
|
|
|
446
464
|
describe("EmbeddedListField — reference column populated via useQuery", () => {
|
|
447
465
|
test("referenceOptions come from the product list query", async () => {
|
|
448
|
-
renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {}, [
|
|
466
|
+
await renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {}, [
|
|
449
467
|
{ id: "p1", name: "Widget A" },
|
|
450
468
|
]);
|
|
451
469
|
await waitFor(() => {
|
|
@@ -456,10 +474,10 @@ describe("EmbeddedListField — reference column populated via useQuery", () =>
|
|
|
456
474
|
});
|
|
457
475
|
|
|
458
476
|
describe("EmbeddedListField — issue grouping wiring", () => {
|
|
459
|
-
test("a lines.0.amount issue is routed as a cellIssue at 0.amount", () => {
|
|
477
|
+
test("a lines.0.amount issue is routed as a cellIssue at 0.amount", async () => {
|
|
460
478
|
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
461
479
|
const issue: FieldIssue = { path: "lines.0.amount", code: "custom", i18nKey: "Bad amount" };
|
|
462
|
-
renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {}, {
|
|
480
|
+
await renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {}, {
|
|
463
481
|
"lines.0.amount": [issue],
|
|
464
482
|
});
|
|
465
483
|
expect(captured?.cellIssues?.["0.amount"]).toEqual([issue]);
|
|
@@ -467,19 +485,19 @@ describe("EmbeddedListField — issue grouping wiring", () => {
|
|
|
467
485
|
expect(captured?.listIssues ?? []).toEqual([]);
|
|
468
486
|
});
|
|
469
487
|
|
|
470
|
-
test("a lines-level issue is routed as a listIssue", () => {
|
|
488
|
+
test("a lines-level issue is routed as a listIssue", async () => {
|
|
471
489
|
const issue: FieldIssue = { path: "lines", code: "custom", i18nKey: "Too few lines" };
|
|
472
|
-
renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {
|
|
490
|
+
await renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {
|
|
473
491
|
lines: [issue],
|
|
474
492
|
});
|
|
475
493
|
expect(captured?.listIssues).toEqual([issue]);
|
|
476
494
|
});
|
|
477
495
|
});
|
|
478
496
|
|
|
479
|
-
function renderFieldWithEmbeddedList(
|
|
497
|
+
async function renderFieldWithEmbeddedList(
|
|
480
498
|
field: EditFieldViewModel,
|
|
481
499
|
allIssues: RenderFieldProps["allIssues"],
|
|
482
|
-
): void {
|
|
500
|
+
): Promise<void> {
|
|
483
501
|
captured = undefined;
|
|
484
502
|
render(
|
|
485
503
|
<LocaleProvider
|
|
@@ -498,13 +516,14 @@ function renderFieldWithEmbeddedList(
|
|
|
498
516
|
</DispatcherProvider>
|
|
499
517
|
</LocaleProvider>,
|
|
500
518
|
);
|
|
519
|
+
await act(async () => {});
|
|
501
520
|
}
|
|
502
521
|
|
|
503
522
|
describe("RenderField — routes embedded-list fields to EmbeddedListField (plumbing)", () => {
|
|
504
|
-
test("field.embeddedListCells set → RenderField mounts EmbeddedListField, allIssues propagate", () => {
|
|
523
|
+
test("field.embeddedListCells set → RenderField mounts EmbeddedListField, allIssues propagate", async () => {
|
|
505
524
|
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
506
525
|
const issue: FieldIssue = { path: "lines.0.amount", code: "custom", i18nKey: "Bad amount" };
|
|
507
|
-
renderFieldWithEmbeddedList(invoiceLinesField({ value: rows }), {
|
|
526
|
+
await renderFieldWithEmbeddedList(invoiceLinesField({ value: rows }), {
|
|
508
527
|
"lines.0.amount": [issue],
|
|
509
528
|
});
|
|
510
529
|
expect(captured?.cellIssues?.["0.amount"]).toEqual([issue]);
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -188,6 +188,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
188
188
|
"errors.notFound": "Not found.",
|
|
189
189
|
"errors.conflict": "Conflict — the operation could not be completed.",
|
|
190
190
|
"errors.versionConflict": "The record was modified in the meantime. Reload and try again.",
|
|
191
|
+
"errors.preconditionFailed": "The record changed in the meantime. Reload and try again.",
|
|
191
192
|
"errors.uniqueViolation": "This entry already exists.",
|
|
192
193
|
"errors.unprocessable": "The request could not be processed.",
|
|
193
194
|
"errors.unconfigured": "This feature isn't configured yet.",
|