@cosmicdrift/kumiko-renderer 0.220.0 → 0.221.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.
@@ -16,10 +16,6 @@ function isPresent(value: unknown): boolean {
16
16
  return true;
17
17
  }
18
18
 
19
- // A statically-`required: true` field with no bound widget is caught
20
- // loudly at boot — validateNoWidgetRequiredField in
21
- // packages/framework/src/engine/boot-validator/screens.ts, sharing
22
- // NO_WIDGET_FIELD_TYPES with the presence check below.
23
19
  function isEmbeddedListField(field: FieldDefinition): boolean {
24
20
  return field.type === "embedded" && field.multiple === true;
25
21
  }
@@ -67,7 +63,7 @@ export function buildFormSchema(
67
63
  const field = entity.fields[spec.field];
68
64
  if (!field) continue;
69
65
  // Not operable by the user — a presence error would be unresolvable,
70
- // same reason as the FIELD_TYPES_WITHOUT_WIDGET check below.
66
+ // same reason as the NO_WIDGET_FIELD_TYPES check below.
71
67
  if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record)) continue;
72
68
  // Screen-spec `required` overrides the entity default, mirroring
73
69
  // `view-model/edit.ts` — the rendered form is the reference, and a
@@ -78,7 +74,9 @@ export function buildFormSchema(
78
74
  if (!isRequired) continue;
79
75
  // Embedded LIST fields get their own EmbeddedListField grid widget
80
76
  // (#1838) — they're fillable, so NO_WIDGET_FIELD_TYPES's "embedded"
81
- // entry must not exempt them from the presence check below.
77
+ // entry must not exempt them. A statically-`required: true` field
78
+ // with no bound widget is also caught at boot
79
+ // (validateNoWidgetRequiredField in boot-validator/screens.ts).
82
80
  if (NO_WIDGET_FIELD_TYPES.includes(field.type) && !isEmbeddedListField(field)) continue;
83
81
  if (isPresent(record[spec.field])) continue;
84
82
  ctx.addIssue({
@@ -341,11 +341,24 @@ 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
  }
348
354
 
355
+ function multiSelectOptionValues(shape: {
356
+ readonly options?: readonly (string | { readonly value: string })[];
357
+ }): ReadonlySet<string> | undefined {
358
+ if (shape.options === undefined || shape.options.length === 0) return undefined;
359
+ return new Set(shape.options.map((o) => (typeof o === "string" ? o : o.value)));
360
+ }
361
+
349
362
  export function mergeSearchParamsIntoInitial(
350
363
  fields: Readonly<Record<string, unknown>>,
351
364
  searchParams: Readonly<Record<string, string>>,
@@ -356,7 +369,11 @@ export function mergeSearchParamsIntoInitial(
356
369
  const merged: Record<string, unknown> = { ...defaults };
357
370
  for (const [name, fieldDef] of Object.entries(fields)) {
358
371
  if (renderableFields !== undefined && !renderableFields.has(name)) continue;
359
- const shape = fieldDef as { type?: string; sensitive?: boolean };
372
+ const shape = fieldDef as {
373
+ type?: string;
374
+ sensitive?: boolean;
375
+ options?: readonly (string | { readonly value: string })[];
376
+ };
360
377
  if (shape.sensitive === true) continue;
361
378
  const raw = searchParams[name];
362
379
  if (raw === undefined) continue;
@@ -372,6 +389,39 @@ export function mergeSearchParamsIntoInitial(
372
389
  : parsed;
373
390
  } else if (shape.type === "boolean") {
374
391
  merged[name] = raw === "true";
392
+ } else if (shape.type === "multiSelect") {
393
+ // Row-action navigate stringifies arrays via String(arr) → "a,b" (or
394
+ // JSON when the navigate helper JSON.stringifies). Try JSON first
395
+ // (even without a "[" prefix), then comma-split; filter against
396
+ // field.options when present so unknown values never reach the form.
397
+ const optionValues = multiSelectOptionValues(shape);
398
+ let values: string[];
399
+ try {
400
+ const parsed: unknown = JSON.parse(raw);
401
+ if (Array.isArray(parsed)) {
402
+ values = parsed.map((v) => String(v));
403
+ } else if (typeof parsed === "string") {
404
+ values = [parsed];
405
+ } else {
406
+ values =
407
+ raw === ""
408
+ ? []
409
+ : raw
410
+ .split(",")
411
+ .map((s) => s.trim())
412
+ .filter(Boolean);
413
+ }
414
+ } catch {
415
+ values =
416
+ raw === ""
417
+ ? []
418
+ : raw
419
+ .split(",")
420
+ .map((s) => s.trim())
421
+ .filter(Boolean);
422
+ }
423
+ merged[name] =
424
+ optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
375
425
  } else {
376
426
  merged[name] = raw;
377
427
  }
@@ -884,6 +934,27 @@ function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[
884
934
  // server). Deliberately NOT gated on the field being a *facet* — entityList
885
935
  // passes through any field present in entity.fields, matching its
886
936
  // pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
937
+
938
+ function stringifyNavParams(params: Record<string, unknown>): Record<string, string | null> {
939
+ const out: Record<string, string | null> = {};
940
+ for (const [k, v] of Object.entries(params)) {
941
+ out[k] =
942
+ v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
943
+ }
944
+ return out;
945
+ }
946
+
947
+ function isFacetI18nKey(label: string): boolean {
948
+ return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
949
+ }
950
+
951
+ async function refetchAfterWrite(refetch: () => Promise<unknown>): Promise<void> {
952
+ await refetch().catch((err: unknown) => {
953
+ // biome-ignore lint/suspicious/noConsole: refetch must not poison the write-action error path
954
+ console.error("kumiko-screen: refetch after write action failed", err);
955
+ });
956
+ }
957
+
887
958
  function buildFilterPayload(
888
959
  urlFilters: Readonly<Record<string, readonly string[]>>,
889
960
  typeOf: (field: string) => string | undefined,
@@ -961,24 +1032,25 @@ function resolveProjectionFacetSpecs(
961
1032
  translate: Translate,
962
1033
  ): ResolvedFacetSpec[] {
963
1034
  if (facets === undefined) return [];
1035
+ const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
964
1036
  return facets.map((facet) =>
965
1037
  facet.type === "select"
966
1038
  ? {
967
1039
  field: facet.field,
968
1040
  type: "select",
969
- label: translate(facet.label),
1041
+ label: tr(facet.label),
970
1042
  options: facet.options.map((opt) => ({
971
1043
  value: opt.value,
972
- label: translate(opt.label),
1044
+ label: tr(opt.label),
973
1045
  })),
974
1046
  }
975
1047
  : {
976
1048
  field: facet.field,
977
1049
  type: "boolean",
978
- label: translate(facet.label),
1050
+ label: tr(facet.label),
979
1051
  options: [
980
- { value: "true", label: translate(facet.trueLabel) },
981
- { value: "false", label: translate(facet.falseLabel) },
1052
+ { value: "true", label: tr(facet.trueLabel) },
1053
+ { value: "false", label: tr(facet.falseLabel) },
982
1054
  ],
983
1055
  },
984
1056
  );
@@ -1229,6 +1301,16 @@ function EntityListBody({
1229
1301
 
1230
1302
  const rowsQuery = useQuery<PagedRows>(queryType, queryPayload, { live: true });
1231
1303
 
1304
+ const refreshRowsAfterWrite = useCallback(async () => {
1305
+ if (useInfinite) {
1306
+ setAccumulated([]);
1307
+ setCursor(undefined);
1308
+ setHasMore(true);
1309
+ return;
1310
+ }
1311
+ await refetchAfterWrite(rowsQuery.refetch);
1312
+ }, [useInfinite, rowsQuery.refetch]);
1313
+
1232
1314
  // Infinite-Scroll: bei jedem erfolgreichen Result die rows appenden +
1233
1315
  // hasMore aus nextCursor ableiten. Live-Updates (postgres NOTIFY) und
1234
1316
  // initiale Loads laufen beide hier durch — das useEffect-Dep-Array
@@ -1313,7 +1395,11 @@ function EntityListBody({
1313
1395
  // detailFor cross-feature nicht selbst nachschlagen.
1314
1396
  const explicit =
1315
1397
  action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1316
- const id = explicit ?? String(row.values["id"] ?? "");
1398
+ // Same-entity only cross-entity targets must declare entityId (boot-validator).
1399
+ const fallback =
1400
+ action.entity === screen.entity ? String(row.values["id"] ?? "") : undefined;
1401
+ const id = explicit ?? fallback ?? "";
1402
+ if (id === "") return;
1317
1403
  nav.navigate({ entity: action.entity, id });
1318
1404
  } else if (action.screen !== undefined) {
1319
1405
  // Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
@@ -1333,6 +1419,8 @@ function EntityListBody({
1333
1419
  screenId: action.screen,
1334
1420
  ...(entityId !== undefined && entityId !== "" && { entityId }),
1335
1421
  });
1422
+ } else {
1423
+ return;
1336
1424
  }
1337
1425
  const params =
1338
1426
  action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
@@ -1341,11 +1429,7 @@ function EntityListBody({
1341
1429
  // kennt via URL nur Strings). Known-edge: zielt die Action auf den
1342
1430
  // AKTUELLEN pathname, mergen die Params auf den alten ?-String (für
1343
1431
  // Row-Actions praktisch nicht erreichbar, Pfad differiert).
1344
- const stringified: Record<string, string | null> = {};
1345
- for (const [k, v] of Object.entries(params)) {
1346
- stringified[k] = v === null || v === undefined ? null : String(v);
1347
- }
1348
- nav.setSearchParams(stringified);
1432
+ nav.setSearchParams(stringifyNavParams(params));
1349
1433
  }
1350
1434
  },
1351
1435
  [nav, schema.screens, screen.entity],
@@ -1400,9 +1484,7 @@ function EntityListBody({
1400
1484
  dispatcherErrorText(result.error, effectiveTranslate),
1401
1485
  );
1402
1486
  }
1403
- // Refetch — without a redirect nothing else remounts the screen,
1404
- // so the list would otherwise keep showing stale rows.
1405
- await rowsQuery.refetch();
1487
+ await refreshRowsAfterWrite();
1406
1488
  },
1407
1489
  isVisible:
1408
1490
  writeActionVisible !== undefined
@@ -1411,7 +1493,7 @@ function EntityListBody({
1411
1493
  };
1412
1494
  })
1413
1495
  .filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
1414
- }, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, rowsQuery.refetch]);
1496
+ }, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, refreshRowsAfterWrite]);
1415
1497
 
1416
1498
  // ToolbarActions: Schema → Resolved-Form (analog rowActions).
1417
1499
  // navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
@@ -1461,13 +1543,19 @@ function EntityListBody({
1461
1543
  dispatcherErrorText(result.error, effectiveTranslate),
1462
1544
  );
1463
1545
  }
1464
- // Same refetch as rowActions above.
1465
- await rowsQuery.refetch();
1546
+ await refreshRowsAfterWrite();
1466
1547
  },
1467
1548
  };
1468
1549
  })
1469
1550
  .filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
1470
- }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch, openDrawer]);
1551
+ }, [
1552
+ screen.toolbarActions,
1553
+ effectiveTranslate,
1554
+ nav,
1555
+ dispatcher,
1556
+ refreshRowsAfterWrite,
1557
+ openDrawer,
1558
+ ]);
1471
1559
 
1472
1560
  if (rowsQuery.loading && rowsQuery.data === null) {
1473
1561
  return (
@@ -1691,6 +1779,7 @@ function ProjectionListBody({
1691
1779
  // Validator erzwingt deshalb einen expliziten entityId für
1692
1780
  // projectionList-entity-Targets.
1693
1781
  const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
1782
+ if (id === "") return;
1694
1783
  nav.navigate({ entity: action.entity, id });
1695
1784
  } else if (action.screen !== undefined) {
1696
1785
  const entityId =
@@ -1699,15 +1788,13 @@ function ProjectionListBody({
1699
1788
  screenId: action.screen,
1700
1789
  ...(entityId !== undefined && entityId !== "" && { entityId }),
1701
1790
  });
1791
+ } else {
1792
+ return;
1702
1793
  }
1703
1794
  const params =
1704
1795
  action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1705
1796
  if (params !== undefined) {
1706
- const stringified: Record<string, string | null> = {};
1707
- for (const [k, v] of Object.entries(params)) {
1708
- stringified[k] = v === null || v === undefined ? null : String(v);
1709
- }
1710
- nav.setSearchParams(stringified);
1797
+ nav.setSearchParams(stringifyNavParams(params));
1711
1798
  }
1712
1799
  },
1713
1800
  [nav],
@@ -1760,7 +1847,7 @@ function ProjectionListBody({
1760
1847
  );
1761
1848
  }
1762
1849
  // Same refetch as EntityListBody's rowActions above.
1763
- await rowsQuery.refetch();
1850
+ await refetchAfterWrite(rowsQuery.refetch);
1764
1851
  },
1765
1852
  ...(writeVisible !== undefined && {
1766
1853
  isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
@@ -1815,7 +1902,7 @@ function ProjectionListBody({
1815
1902
  );
1816
1903
  }
1817
1904
  // Same refetch as rowActions above.
1818
- await rowsQuery.refetch();
1905
+ await refetchAfterWrite(rowsQuery.refetch);
1819
1906
  },
1820
1907
  });
1821
1908
  }
@@ -2003,11 +2090,7 @@ function ProjectionDetailBody({
2003
2090
  const params =
2004
2091
  action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
2005
2092
  if (params !== undefined) {
2006
- const stringified: Record<string, string | null> = {};
2007
- for (const [k, v] of Object.entries(params)) {
2008
- stringified[k] = v === null || v === undefined ? null : String(v);
2009
- }
2010
- nav.setSearchParams(stringified);
2093
+ nav.setSearchParams(stringifyNavParams(params));
2011
2094
  }
2012
2095
  };
2013
2096
  if (action.entity !== undefined) {
@@ -2023,6 +2106,7 @@ function ProjectionDetailBody({
2023
2106
  label: effectiveTranslate(action.label),
2024
2107
  ...(action.style !== undefined && { style: action.style }),
2025
2108
  onPress: () => {
2109
+ if (id === "") return;
2026
2110
  nav.navigate({ entity: targetEntity, id });
2027
2111
  runParams();
2028
2112
  },
@@ -2202,14 +2286,19 @@ function ActionFormBody({
2202
2286
  // Author entscheidet bewusst ob "stay on form" (default) oder
2203
2287
  // "back to list" (typisch bei Create-style Aktionen).
2204
2288
  if (screen.redirect !== undefined) {
2289
+ const targetId = lastSegment(screen.redirect);
2290
+ const target = schema.screens.find((s) => lastSegment(s.id) === targetId);
2205
2291
  const entityId = extractCreatedId(result.data);
2292
+ const carriesId =
2293
+ target !== undefined &&
2294
+ (target.type === "entityEdit" || target.type === "projectionDetail");
2206
2295
  nav.navigate({
2207
- screenId: lastSegment(screen.redirect),
2208
- ...(entityId !== undefined && { entityId }),
2296
+ screenId: targetId,
2297
+ ...(carriesId && entityId !== undefined && { entityId }),
2209
2298
  });
2210
2299
  }
2211
2300
  },
2212
- [nav, screen.redirect, onSuccess],
2301
+ [nav, screen.redirect, onSuccess, schema.screens],
2213
2302
  );
2214
2303
  // Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
2215
2304
  // sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
@@ -117,6 +117,10 @@ describe("RenderEditActionButton", () => {
117
117
  expect(rtlScreen.queryByTestId("render-edit-action-delete-dialog-description")).toBeNull();
118
118
  expect(pressed).toBe(0);
119
119
 
120
+ expect(rtlScreen.getByTestId("render-edit-action-delete-dialog-confirm").textContent).toBe(
121
+ "Delete",
122
+ );
123
+
120
124
  fireEvent.click(rtlScreen.getByTestId("render-edit-action-delete-dialog-confirm"));
121
125
  await waitFor(() => expect(pressed).toBe(1));
122
126
  });
@@ -138,6 +142,7 @@ describe("RenderEditActionButton", () => {
138
142
 
139
143
  fireEvent.click(rtlScreen.getByTestId("render-edit-action-boom"));
140
144
  await waitFor(() => expect(errors).toContain("action exploded"));
145
+ // Cleared at the start of trigger, then set on failure.
141
146
  expect(errors[0]).toBeNull();
142
147
  });
143
148
 
@@ -292,8 +292,33 @@ describe("RenderEdit — submit path", () => {
292
292
  expect(submitted).toEqual({ validationBlocked: true, isSuccess: false });
293
293
  expect(customCalls).toBe(0);
294
294
  });
295
- });
295
+ test("failed writeCommand with field issues suppresses the form-error banner", async () => {
296
+ const writeFailure: DispatcherError = {
297
+ code: "validation_failed",
298
+ httpStatus: 422,
299
+ i18nKey: "kumiko.errors.validation",
300
+ message: "validation failed",
301
+ details: {
302
+ fields: [{ path: "name", code: "too_small", i18nKey: "kumiko.errors.required" }],
303
+ },
304
+ };
305
+ const { dispatcher } = stubDispatcher(async () => ({
306
+ isSuccess: false,
307
+ error: writeFailure,
308
+ }));
309
+ renderEdit(
310
+ oneFieldScreen,
311
+ { writeCommand: "contacts:write:contact:update" },
312
+ buildEntity(),
313
+ dispatcher,
314
+ );
296
315
 
316
+ fireEvent.change(rtlScreen.getByLabelText(/name/i), { target: { value: "Ada" } });
317
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
318
+
319
+ await waitFor(() => expect(rtlScreen.queryByTestId("render-edit-form-error")).toBeNull());
320
+ });
321
+ });
297
322
  describe("RenderEdit — custom actions", () => {
298
323
  test("renders an action button and runs its handler on click", async () => {
299
324
  let pressed = 0;
@@ -388,4 +413,30 @@ describe("RenderEdit — writeCommand path", () => {
388
413
  await waitFor(() => expect(rtlScreen.getByTestId("render-edit-form-error")).toBeTruthy());
389
414
  expect(submitted?.isSuccess).toBe(false);
390
415
  });
416
+ test("failed writeCommand with field issues suppresses the form-error banner", async () => {
417
+ const writeFailure: DispatcherError = {
418
+ code: "validation_failed",
419
+ httpStatus: 422,
420
+ i18nKey: "kumiko.errors.validation",
421
+ message: "validation failed",
422
+ details: {
423
+ fields: [{ path: "name", code: "too_small", i18nKey: "kumiko.errors.required" }],
424
+ },
425
+ };
426
+ const { dispatcher } = stubDispatcher(async () => ({
427
+ isSuccess: false,
428
+ error: writeFailure,
429
+ }));
430
+ renderEdit(
431
+ oneFieldScreen,
432
+ { writeCommand: "contacts:write:contact:update" },
433
+ buildEntity(),
434
+ dispatcher,
435
+ );
436
+
437
+ fireEvent.change(rtlScreen.getByLabelText(/name/i), { target: { value: "Ada" } });
438
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
439
+
440
+ await waitFor(() => expect(rtlScreen.queryByTestId("render-edit-form-error")).toBeNull());
441
+ });
391
442
  });
@@ -3,6 +3,10 @@ import { useState } from "react";
3
3
  import type { usePrimitives } from "../primitives";
4
4
  import type { RenderEditAction } from "./render-edit-types";
5
5
 
6
+ // One header action + its own busy/confirm state — same pattern as
7
+ // render-list.tsx's ToolbarActionView (each RenderEditAction is
8
+ // independently bound by the caller, there is no shared trigger pipeline
9
+ // to hook into like the built-in onDelete/onSubmit paths have).
6
10
  export function RenderEditActionButton({
7
11
  action,
8
12
  Button,