@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.219.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.219.0",
19
- "@cosmicdrift/kumiko-headless": "0.219.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.219.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" ? false : shape.type === "number" || shape.type === "money" ? 0 : "";
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] = v === null || v === undefined ? null : String(v);
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] = v === null || v === undefined ? null : String(v);
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] = v === null || v === undefined ? null : String(v);
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
- nav.navigate({ screenId: lastSegment(screen.redirect) });
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
 
@@ -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> = Omit<
27
- FormControllerOptions<TValues, TCtx>,
28
- "submit"
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 the dispatcher to the ambient one, then hand ui-core
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
@@ -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 "3xl" — see FormWidth
703
- * (`packages/types/src/screen.ts`, EditLayout.width, #1676). Native
704
- * impls may ignore this prop (no width constraint there). */
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