@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.
@@ -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 { ReactNode } from "react";
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 ×100.
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 * 100) : undefined;
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) return;
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
- updatedRow = { ...updatedRow, [column.field]: coerceCellValue(column, text) };
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={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
- // Restricts rendered sections to the caller's `fields` filter, keeping
44
- // section order/title/visibility from the layout unchanged. A `fields`
45
- // section whose filtered field list is empty is dropped entirely — an empty
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,