@cosmicdrift/kumiko-renderer 0.255.2 → 0.256.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.255.2",
3
+ "version": "0.256.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,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.255.2",
19
- "@cosmicdrift/kumiko-headless": "0.255.2",
18
+ "@cosmicdrift/kumiko-framework": "0.256.0",
19
+ "@cosmicdrift/kumiko-headless": "0.256.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.255.2"
30
+ "@cosmicdrift/kumiko-locale-de": "0.256.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import { mergeSearchParamsIntoInitial } from "../app/kumiko-screen";
3
3
 
4
4
  type FieldDef = {
@@ -121,4 +121,79 @@ describe("mergeSearchParamsIntoInitial", () => {
121
121
  mergeSearchParamsIntoInitial(fields, { roles: JSON.stringify(["Admin", "Hacker"]) })["roles"],
122
122
  ).toEqual(["Admin"]);
123
123
  });
124
+
125
+ test("money-type field parses a JSON {amount, currency} param without a defaultCurrency (fw#2763)", () => {
126
+ const fields: Record<string, FieldDef> = { price: { type: "money" } };
127
+ const result = mergeSearchParamsIntoInitial(fields, {
128
+ price: JSON.stringify({ amount: 19.99, currency: "CHF" }),
129
+ });
130
+ expect(result["price"]).toEqual({ amount: 19.99, currency: "CHF" });
131
+ });
132
+
133
+ test("money-type field: explicit JSON currency wins over defaultCurrency", () => {
134
+ const fields: Record<string, FieldDef> = { price: { type: "money" } };
135
+ const result = mergeSearchParamsIntoInitial(
136
+ fields,
137
+ { price: JSON.stringify({ amount: 19.99, currency: "CHF" }) },
138
+ undefined,
139
+ "USD",
140
+ );
141
+ expect(result["price"]).toEqual({ amount: 19.99, currency: "CHF" });
142
+ });
143
+
144
+ test("money-type field: bare number without a defaultCurrency keeps the number and warns (fw#2763)", () => {
145
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
146
+ try {
147
+ const fields: Record<string, FieldDef> = { price: { type: "money" } };
148
+ const result = mergeSearchParamsIntoInitial(fields, { price: "19.99" });
149
+ expect(result["price"]).toBe(19.99);
150
+ expect(warnSpy).toHaveBeenCalled();
151
+ expect(warnSpy.mock.calls[0]?.[0]).toContain("price");
152
+ } finally {
153
+ warnSpy.mockRestore();
154
+ }
155
+ });
156
+
157
+ test("money-type field: JSON object with a broken shape falls back to the default and warns", () => {
158
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
159
+ try {
160
+ const fields: Record<string, FieldDef> = {
161
+ price: { type: "money", default: { amount: 0, currency: "EUR" } },
162
+ };
163
+ const result = mergeSearchParamsIntoInitial(fields, { price: JSON.stringify({ amount: 5 }) });
164
+ expect(result["price"]).toEqual({ amount: 0, currency: "EUR" });
165
+ expect(warnSpy).toHaveBeenCalled();
166
+ } finally {
167
+ warnSpy.mockRestore();
168
+ }
169
+ });
170
+
171
+ test("money-type field: JSON object with a malformed currency code falls back to the default and warns", () => {
172
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
173
+ try {
174
+ const fields: Record<string, FieldDef> = {
175
+ price: { type: "money", default: { amount: 0, currency: "EUR" } },
176
+ };
177
+ const result = mergeSearchParamsIntoInitial(fields, {
178
+ price: JSON.stringify({ amount: 5, currency: "<script>" }),
179
+ });
180
+ expect(result["price"]).toEqual({ amount: 0, currency: "EUR" });
181
+ expect(warnSpy).toHaveBeenCalled();
182
+ } finally {
183
+ warnSpy.mockRestore();
184
+ }
185
+ });
186
+
187
+ test("money-type field: a non-finite bare number falls back to the default (fw#2763)", () => {
188
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
189
+ try {
190
+ const fields: Record<string, FieldDef> = {
191
+ price: { type: "money", default: { amount: 0, currency: "EUR" } },
192
+ };
193
+ const result = mergeSearchParamsIntoInitial(fields, { price: "1e999" });
194
+ expect(result["price"]).toEqual({ amount: 0, currency: "EUR" });
195
+ } finally {
196
+ warnSpy.mockRestore();
197
+ }
198
+ });
124
199
  });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { stringifyNavParams } from "../app/row-actions";
3
+
4
+ describe("stringifyNavParams", () => {
5
+ test("plain object encodes as JSON (fw#2763)", () => {
6
+ const result = stringifyNavParams({ price: { amount: 12.5, currency: "EUR" } });
7
+ expect(result["price"]).toBe(JSON.stringify({ amount: 12.5, currency: "EUR" }));
8
+ });
9
+
10
+ test("array stays JSON", () => {
11
+ const result = stringifyNavParams({ roles: ["Admin", "User"] });
12
+ expect(result["roles"]).toBe(JSON.stringify(["Admin", "User"]));
13
+ });
14
+
15
+ test("string, number and boolean stringify via String()", () => {
16
+ const result = stringifyNavParams({ name: "Alice", age: 42, active: true });
17
+ expect(result["name"]).toBe("Alice");
18
+ expect(result["age"]).toBe("42");
19
+ expect(result["active"]).toBe("true");
20
+ });
21
+
22
+ test("null and undefined become null", () => {
23
+ const result = stringifyNavParams({ a: null, b: undefined });
24
+ expect(result["a"]).toBeNull();
25
+ expect(result["b"]).toBeNull();
26
+ });
27
+
28
+ test("a Date instance stays String(date), not JSON", () => {
29
+ const date = new Date("2026-01-01T00:00:00.000Z");
30
+ const result = stringifyNavParams({ createdAt: date });
31
+ expect(result["createdAt"]).toBe(String(date));
32
+ });
33
+ });
@@ -337,18 +337,8 @@ function useNavigateToCreateFor(
337
337
  return editScreenId !== undefined ? navigate : undefined;
338
338
  }
339
339
 
340
- // Initial form valuesrespect field.default when the entity declares
341
- // one, otherwise fall back to a type-sane empty value so controlled
342
- // inputs have something to render. Missing this on booleans/numbers
343
- // with a `default: true`/`default: 5` would show the form in a state
344
- // the entity didn't ask for — subtle and easy to miss until a user
345
- // submits and is surprised.
346
- // `defaultCurrency` is only passed by entityEdit call sites — the money
347
- // payload shape it enables (`{amount, currency}`) matches the entity's
348
- // write schema (schema-builder.ts, kumiko-framework#1923). Callers outside
349
- // that path (config-edit, action-form) synthesize their own entity and use
350
- // money as a plain number against a different write contract, so they
351
- // deliberately keep the old bare-`0` default by omitting the argument.
340
+ // `defaultCurrency` is entityEdit-onlyit enables the `{amount, currency}`
341
+ // write shape (fw#1923); config-edit's plain-number contract needs bare `0`.
352
342
  export function buildInitialValues(
353
343
  fields: Readonly<Record<string, unknown>>,
354
344
  defaultCurrency?: string,
@@ -383,6 +373,63 @@ function multiSelectOptionValues(shape: {
383
373
  return new Set(shape.options.map((o) => (typeof o === "string" ? o : o.value)));
384
374
  }
385
375
 
376
+ type MoneyValue = { readonly amount: number; readonly currency: string };
377
+
378
+ // The currency now comes from a user-controlled URL param and reaches
379
+ // `Intl.NumberFormat`, which throws a RangeError on a malformed code — reject
380
+ // it here instead of crashing the form render.
381
+ const CURRENCY_CODE_PATTERN = /^[A-Za-z]{3}$/;
382
+
383
+ function isMoneyValue(value: unknown): value is MoneyValue {
384
+ if (typeof value !== "object" || value === null) return false;
385
+ const { amount, currency } = value as Partial<MoneyValue>;
386
+ return (
387
+ typeof amount === "number" &&
388
+ Number.isFinite(amount) &&
389
+ typeof currency === "string" &&
390
+ CURRENCY_CODE_PATTERN.test(currency)
391
+ );
392
+ }
393
+
394
+ function parseJsonOrRaw(raw: string): unknown {
395
+ try {
396
+ return JSON.parse(raw);
397
+ } catch {
398
+ return raw;
399
+ }
400
+ }
401
+
402
+ function warnMoneyParam(message: string): void {
403
+ // biome-ignore lint/suspicious/noConsole: dev-warning for an authoring error
404
+ console.warn(`[kumiko] ${message}`);
405
+ }
406
+
407
+ // An actionForm has no entity and thus no defaultCurrency, so a bare-number
408
+ // prefill would stay a number the handler's zod schema rejects (fw#2763).
409
+ function coerceMoneyValue(
410
+ raw: unknown,
411
+ fieldName: string,
412
+ defaultCurrency?: string,
413
+ ): MoneyValue | number | undefined {
414
+ if (raw === null || raw === undefined) return undefined;
415
+ const value = typeof raw === "string" ? parseJsonOrRaw(raw) : raw;
416
+ if (value === null) return undefined;
417
+ if (isMoneyValue(value)) return { amount: value.amount, currency: value.currency.toUpperCase() };
418
+ if (typeof value === "object") {
419
+ warnMoneyParam(
420
+ `money field "${fieldName}" got an object without a usable {amount, currency} shape — the prefill is ignored.`,
421
+ );
422
+ return undefined;
423
+ }
424
+ const amount = Number(typeof value === "number" ? value : raw);
425
+ if (!Number.isFinite(amount)) return undefined;
426
+ if (defaultCurrency !== undefined) return { amount, currency: defaultCurrency };
427
+ warnMoneyParam(
428
+ `money field "${fieldName}" was prefilled with a bare number and no currency is available — pass {"amount":<number>,"currency":"<ISO code>"} as the param value, otherwise the handler's schema rejects the submit.`,
429
+ );
430
+ return amount;
431
+ }
432
+
386
433
  export function mergeSearchParamsIntoInitial(
387
434
  fields: Readonly<Record<string, unknown>>,
388
435
  searchParams: Readonly<Record<string, string>>,
@@ -415,12 +462,8 @@ export function mergeSearchParamsIntoInitial(
415
462
  const parsed = Number(raw);
416
463
  merged[name] = Number.isNaN(parsed) ? defaults[name] : parsed;
417
464
  } else if (shape.type === "money") {
418
- const parsed = Number(raw);
419
- merged[name] = Number.isNaN(parsed)
420
- ? defaults[name]
421
- : defaultCurrency !== undefined
422
- ? { amount: parsed, currency: defaultCurrency }
423
- : parsed;
465
+ const coerced = coerceMoneyValue(raw, name, defaultCurrency);
466
+ merged[name] = coerced === undefined ? defaults[name] : coerced;
424
467
  } else if (shape.type === "boolean") {
425
468
  merged[name] = raw === "true";
426
469
  } else if (shape.type === "multiSelect") {
@@ -81,11 +81,23 @@ export function resolveActionIcon(id: string, declared?: IconKey): IconKey | und
81
81
  );
82
82
  }
83
83
 
84
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
85
+ if (typeof value !== "object" || value === null) return false;
86
+ const proto: object | null = Object.getPrototypeOf(value);
87
+ return proto === Object.prototype || proto === null;
88
+ }
89
+
84
90
  export function stringifyNavParams(params: Record<string, unknown>): Record<string, string | null> {
85
91
  const out: Record<string, string | null> = {};
86
92
  for (const [k, v] of Object.entries(params)) {
93
+ // Structured field values such as money `{amount, currency}` must survive
94
+ // the URL round-trip as JSON; Date & other class instances stay String().
87
95
  out[k] =
88
- v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
96
+ v === null || v === undefined
97
+ ? null
98
+ : Array.isArray(v) || isPlainObject(v)
99
+ ? JSON.stringify(v)
100
+ : String(v);
89
101
  }
90
102
  return out;
91
103
  }