@cosmicdrift/kumiko-renderer 0.219.0 → 0.220.1
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.220.1",
|
|
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.220.1",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.220.1",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@testing-library/react": "^16.3.2",
|
|
26
26
|
"@types/react": "^19.2.14",
|
|
27
27
|
"jsdom": "^29.1.1",
|
|
28
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
28
|
+
"@cosmicdrift/kumiko-locale-de": "0.220.1"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -77,4 +77,27 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
77
77
|
const result = mergeSearchParamsIntoInitial(fields, { ownerId: "user-123" });
|
|
78
78
|
expect(result["ownerId"]).toBe("user-123");
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
test("multiSelect coerces comma-separated searchParam to string[]", () => {
|
|
82
|
+
const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
|
|
83
|
+
expect(mergeSearchParamsIntoInitial(fields, { roles: "TenantAdmin" })["roles"]).toEqual([
|
|
84
|
+
"TenantAdmin",
|
|
85
|
+
]);
|
|
86
|
+
expect(mergeSearchParamsIntoInitial(fields, { roles: "Admin,User" })["roles"]).toEqual([
|
|
87
|
+
"Admin",
|
|
88
|
+
"User",
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("multiSelect coerces JSON-array searchParam to string[]", () => {
|
|
93
|
+
const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
|
|
94
|
+
expect(
|
|
95
|
+
mergeSearchParamsIntoInitial(fields, { roles: JSON.stringify(["Admin", "Editor"]) })["roles"],
|
|
96
|
+
).toEqual(["Admin", "Editor"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("multiSelect defaults to [] when unset", () => {
|
|
100
|
+
const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
|
|
101
|
+
expect(mergeSearchParamsIntoInitial(fields, {})["roles"]).toEqual([]);
|
|
102
|
+
});
|
|
80
103
|
});
|
|
@@ -341,7 +341,13 @@ export function buildInitialValues(
|
|
|
341
341
|
continue;
|
|
342
342
|
}
|
|
343
343
|
out[name] =
|
|
344
|
-
shape.type === "boolean"
|
|
344
|
+
shape.type === "boolean"
|
|
345
|
+
? false
|
|
346
|
+
: shape.type === "number" || shape.type === "money"
|
|
347
|
+
? 0
|
|
348
|
+
: shape.type === "multiSelect"
|
|
349
|
+
? []
|
|
350
|
+
: "";
|
|
345
351
|
}
|
|
346
352
|
return out;
|
|
347
353
|
}
|
|
@@ -372,6 +378,26 @@ export function mergeSearchParamsIntoInitial(
|
|
|
372
378
|
: parsed;
|
|
373
379
|
} else if (shape.type === "boolean") {
|
|
374
380
|
merged[name] = raw === "true";
|
|
381
|
+
} else if (shape.type === "multiSelect") {
|
|
382
|
+
// Row-action navigate stringifies arrays via String(arr) → "a,b" (or
|
|
383
|
+
// JSON when the navigate helper JSON.stringifies). Accept both so
|
|
384
|
+
// member-roles-edit can prefill from ?roles=TenantAdmin.
|
|
385
|
+
if (raw.startsWith("[")) {
|
|
386
|
+
try {
|
|
387
|
+
const parsed: unknown = JSON.parse(raw);
|
|
388
|
+
merged[name] = Array.isArray(parsed) ? parsed.map((v) => String(v)) : defaults[name];
|
|
389
|
+
} catch {
|
|
390
|
+
merged[name] = defaults[name];
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
merged[name] =
|
|
394
|
+
raw === ""
|
|
395
|
+
? []
|
|
396
|
+
: raw
|
|
397
|
+
.split(",")
|
|
398
|
+
.map((s) => s.trim())
|
|
399
|
+
.filter(Boolean);
|
|
400
|
+
}
|
|
375
401
|
} else {
|
|
376
402
|
merged[name] = raw;
|
|
377
403
|
}
|
|
@@ -1343,7 +1369,8 @@ function EntityListBody({
|
|
|
1343
1369
|
// Row-Actions praktisch nicht erreichbar, Pfad differiert).
|
|
1344
1370
|
const stringified: Record<string, string | null> = {};
|
|
1345
1371
|
for (const [k, v] of Object.entries(params)) {
|
|
1346
|
-
stringified[k] =
|
|
1372
|
+
stringified[k] =
|
|
1373
|
+
v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
|
|
1347
1374
|
}
|
|
1348
1375
|
nav.setSearchParams(stringified);
|
|
1349
1376
|
}
|
|
@@ -1705,7 +1732,8 @@ function ProjectionListBody({
|
|
|
1705
1732
|
if (params !== undefined) {
|
|
1706
1733
|
const stringified: Record<string, string | null> = {};
|
|
1707
1734
|
for (const [k, v] of Object.entries(params)) {
|
|
1708
|
-
stringified[k] =
|
|
1735
|
+
stringified[k] =
|
|
1736
|
+
v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
|
|
1709
1737
|
}
|
|
1710
1738
|
nav.setSearchParams(stringified);
|
|
1711
1739
|
}
|
|
@@ -2005,7 +2033,12 @@ function ProjectionDetailBody({
|
|
|
2005
2033
|
if (params !== undefined) {
|
|
2006
2034
|
const stringified: Record<string, string | null> = {};
|
|
2007
2035
|
for (const [k, v] of Object.entries(params)) {
|
|
2008
|
-
stringified[k] =
|
|
2036
|
+
stringified[k] =
|
|
2037
|
+
v === null || v === undefined
|
|
2038
|
+
? null
|
|
2039
|
+
: Array.isArray(v)
|
|
2040
|
+
? JSON.stringify(v)
|
|
2041
|
+
: String(v);
|
|
2009
2042
|
}
|
|
2010
2043
|
nav.setSearchParams(stringified);
|
|
2011
2044
|
}
|
|
@@ -2202,7 +2235,11 @@ function ActionFormBody({
|
|
|
2202
2235
|
// Author entscheidet bewusst ob "stay on form" (default) oder
|
|
2203
2236
|
// "back to list" (typisch bei Create-style Aktionen).
|
|
2204
2237
|
if (screen.redirect !== undefined) {
|
|
2205
|
-
|
|
2238
|
+
const entityId = extractCreatedId(result.data);
|
|
2239
|
+
nav.navigate({
|
|
2240
|
+
screenId: lastSegment(screen.redirect),
|
|
2241
|
+
...(entityId !== undefined && { entityId }),
|
|
2242
|
+
});
|
|
2206
2243
|
}
|
|
2207
2244
|
},
|
|
2208
2245
|
[nav, screen.redirect, onSuccess],
|
|
@@ -138,6 +138,7 @@ describe("RenderEditActionButton", () => {
|
|
|
138
138
|
|
|
139
139
|
fireEvent.click(rtlScreen.getByTestId("render-edit-action-boom"));
|
|
140
140
|
await waitFor(() => expect(errors).toContain("action exploded"));
|
|
141
|
+
// Cleared at the start of trigger, then set on failure.
|
|
141
142
|
expect(errors[0]).toBeNull();
|
|
142
143
|
});
|
|
143
144
|
|
package/src/hooks/use-form.ts
CHANGED
|
@@ -23,20 +23,10 @@ import { useOptionalDispatcher } from "../context/dispatcher-context";
|
|
|
23
23
|
// });
|
|
24
24
|
// and the hook wires the ambient DispatcherProvider's dispatcher in.
|
|
25
25
|
|
|
26
|
-
export type UseFormOptions<TValues extends FormValues, TCtx = unknown> =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// `submit` here takes everything the ui-core SubmitConfig takes
|
|
31
|
-
// EXCEPT dispatcher — that comes from context. Passing an explicit
|
|
32
|
-
// dispatcher here (e.g. from a test) overrides the context one.
|
|
33
|
-
readonly submit?: Omit<
|
|
34
|
-
NonNullable<FormControllerOptions<TValues, TCtx>["submit"]>,
|
|
35
|
-
"dispatcher"
|
|
36
|
-
> & {
|
|
37
|
-
readonly dispatcher?: NonNullable<FormControllerOptions<TValues, TCtx>["submit"]>["dispatcher"];
|
|
38
|
-
};
|
|
39
|
-
};
|
|
26
|
+
export type UseFormOptions<TValues extends FormValues, TCtx = unknown> = FormControllerOptions<
|
|
27
|
+
TValues,
|
|
28
|
+
TCtx
|
|
29
|
+
>;
|
|
40
30
|
|
|
41
31
|
export type UseFormResult<TValues extends FormValues> = {
|
|
42
32
|
readonly controller: FormController<TValues>;
|
|
@@ -63,10 +53,7 @@ export function useForm<TValues extends FormValues, TCtx = unknown>(
|
|
|
63
53
|
// in-flight state and dirty edits on every parent re-render.
|
|
64
54
|
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — controller is lifetime-scoped, not render-scoped
|
|
65
55
|
const controller = useMemo<FormController<TValues>>(() => {
|
|
66
|
-
// Default
|
|
67
|
-
// a fully-populated SubmitConfig. The input signature lets the
|
|
68
|
-
// caller omit `dispatcher`; the output shape createFormController
|
|
69
|
-
// expects requires it.
|
|
56
|
+
// Default submit.dispatcher to the ambient one when omitted.
|
|
70
57
|
type FullOptions = FormControllerOptions<TValues, TCtx>;
|
|
71
58
|
const rawSubmit = options.submit;
|
|
72
59
|
const submitConfig: FullOptions["submit"] = rawSubmit
|
package/src/primitives.tsx
CHANGED
|
@@ -699,9 +699,9 @@ export type FormProps = {
|
|
|
699
699
|
readonly subtitle?: ReactNode;
|
|
700
700
|
readonly actions?: ReactNode;
|
|
701
701
|
readonly testId?: string;
|
|
702
|
-
/** Max width of the form container. Default "
|
|
703
|
-
* (`packages/types/src/screen.ts`, EditLayout.width
|
|
704
|
-
*
|
|
702
|
+
/** Max width of the form container. Default "full" — see FormWidth
|
|
703
|
+
* (`packages/types/src/screen.ts`, EditLayout.width). Native impls may
|
|
704
|
+
* ignore this prop (no width constraint there). */
|
|
705
705
|
readonly width?: FormWidth;
|
|
706
706
|
/** Pins `actions` to the viewport bottom on narrow screens (`<640px`)
|
|
707
707
|
* instead of normal document flow, so it stays reachable when a virtual
|