@cosmicdrift/kumiko-renderer 0.255.1 → 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.
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
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.
|
|
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
|
-
//
|
|
341
|
-
//
|
|
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-only — it 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
|
|
419
|
-
merged[name] =
|
|
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") {
|
package/src/app/row-actions.ts
CHANGED
|
@@ -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
|
|
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
|
}
|