@cosmicdrift/kumiko-renderer 0.193.1 → 0.195.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 +3 -3
- package/src/__tests__/error-i18n-defaults.test.ts +9 -0
- package/src/app/__tests__/content-editors.test.tsx +24 -5
- package/src/app/__tests__/content-preview.test.tsx +23 -1
- package/src/app/__tests__/form-schema.test.ts +8 -12
- package/src/app/content-editors.tsx +13 -6
- package/src/app/content-preview.tsx +3 -1
- package/src/app/extension-sections.tsx +5 -2
- package/src/app/form-schema.ts +13 -3
- package/src/app/kumiko-screen.tsx +8 -2
- package/src/components/__tests__/embedded-list-field.test.tsx +68 -1
- package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +212 -0
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +14 -0
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +22 -1
- package/src/components/embedded-list-field.tsx +59 -7
- package/src/components/reference-create-dialog.tsx +1 -1
- package/src/components/render-edit-logic.ts +3 -8
- package/src/components/render-edit.tsx +152 -75
- package/src/components/render-field.tsx +15 -3
- package/src/i18n-defaults.ts +23 -5
- package/src/index.ts +1 -0
- package/src/primitives.tsx +28 -7
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
import { computeEditViewModel, type EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
22
22
|
import { render } from "@testing-library/react";
|
|
23
23
|
import type { ComponentType, ReactNode } from "react";
|
|
24
|
+
import { buildInitialValues } from "../../app/kumiko-screen";
|
|
24
25
|
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
25
26
|
import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
|
|
26
27
|
import { RenderField } from "../render-field";
|
|
@@ -116,6 +117,19 @@ describe("RenderField money round-trip (kumiko-framework#1923)", () => {
|
|
|
116
117
|
expect(result.success).toBe(true);
|
|
117
118
|
});
|
|
118
119
|
|
|
120
|
+
test("create: buildInitialValues default renders as 0.00 in the widget, not empty", () => {
|
|
121
|
+
// Regression: the widget was tested with `values: {}` (no field.value at
|
|
122
|
+
// all), which produces "" — but the real entityEdit create path always
|
|
123
|
+
// feeds buildInitialValues(fields, defaultCurrency), which yields
|
|
124
|
+
// {amount: 0, currency} (kumiko-screen.tsx buildInitialValues).
|
|
125
|
+
const entity = buildEntity("USD");
|
|
126
|
+
const initialValues = buildInitialValues(entity.fields, "USD");
|
|
127
|
+
const field = renderMoneyField(entity, initialValues, () => {});
|
|
128
|
+
expect(field.kind).toBe("money");
|
|
129
|
+
if (field.kind !== "money") return;
|
|
130
|
+
expect(field.value).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
119
133
|
test("create: user-entered amount becomes a payload that validates against buildInsertSchema", () => {
|
|
120
134
|
const entity = buildEntity("USD");
|
|
121
135
|
let payload: unknown;
|
|
@@ -98,11 +98,32 @@ describe.each([
|
|
|
98
98
|
expect(capturedInput).toBeUndefined();
|
|
99
99
|
expect(capturedBanner).toBeDefined();
|
|
100
100
|
expect(capturedBanner?.variant).toBe("info");
|
|
101
|
-
expect(capturedBanner?.children).toBe("Dieser Feldtyp kann hier noch nicht bearbeitet werden.");
|
|
102
101
|
// The surrounding <Field> always renders a <label htmlFor={id}> —
|
|
103
102
|
// without an id on the Banner itself that label points at nothing (#1834 review).
|
|
104
103
|
expect(capturedBanner?.id).toBe("kumiko-edit-positions");
|
|
105
104
|
});
|
|
105
|
+
|
|
106
|
+
// #1847#6: the Banner used to show only the generic hint, hiding the
|
|
107
|
+
// actual field value from the user entirely (not even read-only).
|
|
108
|
+
test("shows the underlying value read-only alongside the generic hint", () => {
|
|
109
|
+
renderField(field);
|
|
110
|
+
const children = capturedBanner?.children;
|
|
111
|
+
const childArray = Array.isArray(children) ? children : [children];
|
|
112
|
+
expect(childArray[0]).toBe("Dieser Feldtyp kann hier noch nicht bearbeitet werden.");
|
|
113
|
+
const valuePreview = childArray[1] as { props: { children: unknown } } | false | undefined;
|
|
114
|
+
if (!valuePreview) throw new Error("expected a value-preview element");
|
|
115
|
+
expect(valuePreview.props.children).toBe(JSON.stringify(field.value));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("RenderField — unsupported-type Banner ohne Wert", () => {
|
|
120
|
+
test("kein Value-Preview-Element wenn field.value leer ist", () => {
|
|
121
|
+
renderField(baseField({ type: "jsonb", value: null }));
|
|
122
|
+
const children = capturedBanner?.children;
|
|
123
|
+
const childArray = Array.isArray(children) ? children : [children];
|
|
124
|
+
expect(childArray[0]).toBe("Dieser Feldtyp kann hier noch nicht bearbeitet werden.");
|
|
125
|
+
expect(childArray[1]).toBeFalsy();
|
|
126
|
+
});
|
|
106
127
|
});
|
|
107
128
|
|
|
108
129
|
describe("RenderField — text bleibt weiterhin editierbar", () => {
|
|
@@ -5,11 +5,12 @@ import type {
|
|
|
5
5
|
} from "@cosmicdrift/kumiko-headless";
|
|
6
6
|
import {
|
|
7
7
|
computeDerivedCellValue,
|
|
8
|
+
currencyDecimals,
|
|
8
9
|
groupEmbeddedListIssues,
|
|
9
10
|
roundDerivedCellValue,
|
|
10
11
|
sumEmbeddedListColumn,
|
|
11
12
|
} from "@cosmicdrift/kumiko-headless";
|
|
12
|
-
import type
|
|
13
|
+
import { type ReactNode, useState } from "react";
|
|
13
14
|
import { toKebab } from "../app/qn";
|
|
14
15
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
|
|
15
16
|
import { useQuery } from "../hooks/use-query";
|
|
@@ -54,7 +55,7 @@ function withRecomputedDerived(
|
|
|
54
55
|
return result;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function coerceCellValue(column: EmbeddedListColumn, text: string): unknown {
|
|
58
|
+
function coerceCellValue(column: EmbeddedListColumn, text: string, currency: string): unknown {
|
|
58
59
|
switch (column.type) {
|
|
59
60
|
case "text":
|
|
60
61
|
return text;
|
|
@@ -66,10 +67,15 @@ function coerceCellValue(column: EmbeddedListColumn, text: string): unknown {
|
|
|
66
67
|
}
|
|
67
68
|
case "money": {
|
|
68
69
|
// Money cells are minor-unit integers (cents) in storage — paste
|
|
69
|
-
// arrives as a major-unit decimal string ("12,99"/"12.99"), so
|
|
70
|
+
// arrives as a major-unit decimal string ("12,99"/"12.99"), so scale
|
|
71
|
+
// by the currency's decimal places. Must agree with the typed-in path
|
|
72
|
+
// (renderCellControl's MoneyInput, which scales by the same
|
|
73
|
+
// currencyDecimals) — a hardcoded ×100 here diverged for zero-/three-
|
|
74
|
+
// decimal currencies (JPY, BHD, ...), landing a pasted value 100x off
|
|
75
|
+
// from the same value typed by hand (kumiko-framework#1972).
|
|
70
76
|
if (text.trim() === "") return undefined;
|
|
71
77
|
const n = Number(text.replace(",", "."));
|
|
72
|
-
return Number.isFinite(n) ? Math.round(n *
|
|
78
|
+
return Number.isFinite(n) ? Math.round(n * 10 ** currencyDecimals(currency)) : undefined;
|
|
73
79
|
}
|
|
74
80
|
case "boolean":
|
|
75
81
|
return ["true", "1", "yes", "y", "ja"].includes(text.trim().toLowerCase());
|
|
@@ -104,6 +110,10 @@ export function EmbeddedListField({
|
|
|
104
110
|
}: EmbeddedListFieldProps): ReactNode {
|
|
105
111
|
const { EmbeddedListInput } = usePrimitives();
|
|
106
112
|
const t = useTranslation();
|
|
113
|
+
const [pasteWarning, setPasteWarning] = useState<{
|
|
114
|
+
readonly droppedRows: number;
|
|
115
|
+
readonly unmatchedCells: number;
|
|
116
|
+
} | null>(null);
|
|
107
117
|
|
|
108
118
|
const cells = field.embeddedListCells ?? [];
|
|
109
119
|
const rows = Array.isArray(field.value) ? (field.value as readonly EmbeddedRow[]) : [];
|
|
@@ -163,6 +173,32 @@ export function EmbeddedListField({
|
|
|
163
173
|
});
|
|
164
174
|
|
|
165
175
|
const { listIssues, rowIssues, cellIssues } = groupEmbeddedListIssues(allIssues, field.field);
|
|
176
|
+
const combinedListIssues: readonly FieldIssue[] =
|
|
177
|
+
pasteWarning === null
|
|
178
|
+
? listIssues
|
|
179
|
+
: [
|
|
180
|
+
...listIssues,
|
|
181
|
+
...(pasteWarning.droppedRows > 0
|
|
182
|
+
? [
|
|
183
|
+
{
|
|
184
|
+
path: field.field,
|
|
185
|
+
code: "paste-rows-truncated",
|
|
186
|
+
i18nKey: "kumiko.field.embedded-list.paste-rows-truncated",
|
|
187
|
+
params: { count: pasteWarning.droppedRows },
|
|
188
|
+
},
|
|
189
|
+
]
|
|
190
|
+
: []),
|
|
191
|
+
...(pasteWarning.unmatchedCells > 0
|
|
192
|
+
? [
|
|
193
|
+
{
|
|
194
|
+
path: field.field,
|
|
195
|
+
code: "paste-cells-unmatched",
|
|
196
|
+
i18nKey: "kumiko.field.embedded-list.paste-cells-unmatched",
|
|
197
|
+
params: { count: pasteWarning.unmatchedCells },
|
|
198
|
+
},
|
|
199
|
+
]
|
|
200
|
+
: []),
|
|
201
|
+
];
|
|
166
202
|
|
|
167
203
|
function replaceRow(rowIndex: number, updater: (row: EmbeddedRow) => EmbeddedRow): void {
|
|
168
204
|
const nextRows = rows.map((row, i) => (i === rowIndex ? updater(row) : row));
|
|
@@ -208,11 +244,16 @@ export function EmbeddedListField({
|
|
|
208
244
|
const maxItems = field.embeddedListMaxItems;
|
|
209
245
|
const nextRows = [...rows];
|
|
210
246
|
const touchedIndices = new Set<number>();
|
|
247
|
+
let droppedRows = 0;
|
|
248
|
+
let unmatchedCells = 0;
|
|
211
249
|
|
|
212
250
|
grid.forEach((gridRow, gridRowOffset) => {
|
|
213
251
|
const targetRowIndex = rowIndex + gridRowOffset;
|
|
214
252
|
if (targetRowIndex >= nextRows.length) {
|
|
215
|
-
if (maxItems !== undefined && nextRows.length >= maxItems)
|
|
253
|
+
if (maxItems !== undefined && nextRows.length >= maxItems) {
|
|
254
|
+
droppedRows += 1;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
216
257
|
nextRows.push({});
|
|
217
258
|
}
|
|
218
259
|
const targetRow = nextRows[targetRowIndex];
|
|
@@ -221,12 +262,23 @@ export function EmbeddedListField({
|
|
|
221
262
|
gridRow.forEach((text, gridColOffset) => {
|
|
222
263
|
const column = columns[columnIndex + gridColOffset];
|
|
223
264
|
if (column === undefined) return;
|
|
224
|
-
|
|
265
|
+
const coerced = coerceCellValue(column, text, field.embeddedListCurrency ?? "EUR");
|
|
266
|
+
const isUnmatchedChoice =
|
|
267
|
+
(column.type === "select" || column.type === "reference") &&
|
|
268
|
+
coerced === undefined &&
|
|
269
|
+
text.trim() !== "";
|
|
270
|
+
if (isUnmatchedChoice) {
|
|
271
|
+
unmatchedCells += 1;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
updatedRow = { ...updatedRow, [column.field]: coerced };
|
|
225
275
|
});
|
|
226
276
|
nextRows[targetRowIndex] = updatedRow;
|
|
227
277
|
touchedIndices.add(targetRowIndex);
|
|
228
278
|
});
|
|
229
279
|
|
|
280
|
+
setPasteWarning(droppedRows > 0 || unmatchedCells > 0 ? { droppedRows, unmatchedCells } : null);
|
|
281
|
+
|
|
230
282
|
const recomputed = nextRows.map((row, i) =>
|
|
231
283
|
touchedIndices.has(i) ? withRecomputedDerived(row, derived, cells) : row,
|
|
232
284
|
);
|
|
@@ -243,7 +295,7 @@ export function EmbeddedListField({
|
|
|
243
295
|
disabled={field.readOnly}
|
|
244
296
|
minItems={field.embeddedListMinItems}
|
|
245
297
|
maxItems={field.embeddedListMaxItems}
|
|
246
|
-
listIssues={
|
|
298
|
+
listIssues={combinedListIssues}
|
|
247
299
|
rowIssues={rowIssues}
|
|
248
300
|
cellIssues={cellIssues}
|
|
249
301
|
onCellChange={handleCellChange}
|
|
@@ -23,7 +23,7 @@ function entityWriteCommand(featureName: string, entity: string): string {
|
|
|
23
23
|
// The create handler's success payload is `{ kind: "save", id, ... }`
|
|
24
24
|
// (see event-store-executor-write.ts) but RenderEdit's onSubmit only
|
|
25
25
|
// types it as `unknown` — narrow defensively instead of casting through it.
|
|
26
|
-
function extractCreatedId(data: unknown): string | undefined {
|
|
26
|
+
export function extractCreatedId(data: unknown): string | undefined {
|
|
27
27
|
if (typeof data !== "object" || data === null) return undefined;
|
|
28
28
|
const id = (data as Record<string, unknown>)["id"];
|
|
29
29
|
return typeof id === "string" ? id : undefined;
|
|
@@ -40,14 +40,9 @@ export function shouldNotifyCaller(
|
|
|
40
40
|
return !(result.isSuccess && !extensionsPersisted);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// section container would read as a broken layout, not "nothing to show
|
|
47
|
-
// here". Extension sections are never filtered — they carry their own field
|
|
48
|
-
// set, unrelated to the `field`-name filter. `fieldsFilter === undefined`
|
|
49
|
-
// (no prop passed) returns the same array reference, so callers that skip
|
|
50
|
-
// the prop keep unchanged render behavior.
|
|
43
|
+
// Extension sections skip the `fields` filter (their own field set, unrelated
|
|
44
|
+
// to `field`-name filtering); a `fields` section left with zero fields after
|
|
45
|
+
// filtering is dropped, not rendered empty.
|
|
51
46
|
export function filterEditSections(
|
|
52
47
|
sections: readonly EditSectionViewModel[],
|
|
53
48
|
fieldsFilter: readonly string[] | undefined,
|