@cosmicdrift/kumiko-renderer 0.220.1 → 0.222.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({
@@ -352,6 +352,13 @@ export function buildInitialValues(
352
352
  return out;
353
353
  }
354
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
+
355
362
  export function mergeSearchParamsIntoInitial(
356
363
  fields: Readonly<Record<string, unknown>>,
357
364
  searchParams: Readonly<Record<string, string>>,
@@ -362,7 +369,11 @@ export function mergeSearchParamsIntoInitial(
362
369
  const merged: Record<string, unknown> = { ...defaults };
363
370
  for (const [name, fieldDef] of Object.entries(fields)) {
364
371
  if (renderableFields !== undefined && !renderableFields.has(name)) continue;
365
- 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
+ };
366
377
  if (shape.sensitive === true) continue;
367
378
  const raw = searchParams[name];
368
379
  if (raw === undefined) continue;
@@ -380,17 +391,28 @@ export function mergeSearchParamsIntoInitial(
380
391
  merged[name] = raw === "true";
381
392
  } else if (shape.type === "multiSelect") {
382
393
  // 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];
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);
391
413
  }
392
- } else {
393
- merged[name] =
414
+ } catch {
415
+ values =
394
416
  raw === ""
395
417
  ? []
396
418
  : raw
@@ -398,6 +420,8 @@ export function mergeSearchParamsIntoInitial(
398
420
  .map((s) => s.trim())
399
421
  .filter(Boolean);
400
422
  }
423
+ merged[name] =
424
+ optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
401
425
  } else {
402
426
  merged[name] = raw;
403
427
  }
@@ -910,6 +934,27 @@ function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[
910
934
  // server). Deliberately NOT gated on the field being a *facet* — entityList
911
935
  // passes through any field present in entity.fields, matching its
912
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
+
913
958
  function buildFilterPayload(
914
959
  urlFilters: Readonly<Record<string, readonly string[]>>,
915
960
  typeOf: (field: string) => string | undefined,
@@ -987,24 +1032,25 @@ function resolveProjectionFacetSpecs(
987
1032
  translate: Translate,
988
1033
  ): ResolvedFacetSpec[] {
989
1034
  if (facets === undefined) return [];
1035
+ const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
990
1036
  return facets.map((facet) =>
991
1037
  facet.type === "select"
992
1038
  ? {
993
1039
  field: facet.field,
994
1040
  type: "select",
995
- label: translate(facet.label),
1041
+ label: tr(facet.label),
996
1042
  options: facet.options.map((opt) => ({
997
1043
  value: opt.value,
998
- label: translate(opt.label),
1044
+ label: tr(opt.label),
999
1045
  })),
1000
1046
  }
1001
1047
  : {
1002
1048
  field: facet.field,
1003
1049
  type: "boolean",
1004
- label: translate(facet.label),
1050
+ label: tr(facet.label),
1005
1051
  options: [
1006
- { value: "true", label: translate(facet.trueLabel) },
1007
- { value: "false", label: translate(facet.falseLabel) },
1052
+ { value: "true", label: tr(facet.trueLabel) },
1053
+ { value: "false", label: tr(facet.falseLabel) },
1008
1054
  ],
1009
1055
  },
1010
1056
  );
@@ -1255,6 +1301,16 @@ function EntityListBody({
1255
1301
 
1256
1302
  const rowsQuery = useQuery<PagedRows>(queryType, queryPayload, { live: true });
1257
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
+
1258
1314
  // Infinite-Scroll: bei jedem erfolgreichen Result die rows appenden +
1259
1315
  // hasMore aus nextCursor ableiten. Live-Updates (postgres NOTIFY) und
1260
1316
  // initiale Loads laufen beide hier durch — das useEffect-Dep-Array
@@ -1339,7 +1395,11 @@ function EntityListBody({
1339
1395
  // detailFor cross-feature nicht selbst nachschlagen.
1340
1396
  const explicit =
1341
1397
  action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1342
- 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;
1343
1403
  nav.navigate({ entity: action.entity, id });
1344
1404
  } else if (action.screen !== undefined) {
1345
1405
  // Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
@@ -1359,6 +1419,8 @@ function EntityListBody({
1359
1419
  screenId: action.screen,
1360
1420
  ...(entityId !== undefined && entityId !== "" && { entityId }),
1361
1421
  });
1422
+ } else {
1423
+ return;
1362
1424
  }
1363
1425
  const params =
1364
1426
  action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
@@ -1367,12 +1429,7 @@ function EntityListBody({
1367
1429
  // kennt via URL nur Strings). Known-edge: zielt die Action auf den
1368
1430
  // AKTUELLEN pathname, mergen die Params auf den alten ?-String (für
1369
1431
  // Row-Actions praktisch nicht erreichbar, Pfad differiert).
1370
- const stringified: Record<string, string | null> = {};
1371
- for (const [k, v] of Object.entries(params)) {
1372
- stringified[k] =
1373
- v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
1374
- }
1375
- nav.setSearchParams(stringified);
1432
+ nav.setSearchParams(stringifyNavParams(params));
1376
1433
  }
1377
1434
  },
1378
1435
  [nav, schema.screens, screen.entity],
@@ -1427,9 +1484,7 @@ function EntityListBody({
1427
1484
  dispatcherErrorText(result.error, effectiveTranslate),
1428
1485
  );
1429
1486
  }
1430
- // Refetch — without a redirect nothing else remounts the screen,
1431
- // so the list would otherwise keep showing stale rows.
1432
- await rowsQuery.refetch();
1487
+ await refreshRowsAfterWrite();
1433
1488
  },
1434
1489
  isVisible:
1435
1490
  writeActionVisible !== undefined
@@ -1438,7 +1493,7 @@ function EntityListBody({
1438
1493
  };
1439
1494
  })
1440
1495
  .filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
1441
- }, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, rowsQuery.refetch]);
1496
+ }, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, refreshRowsAfterWrite]);
1442
1497
 
1443
1498
  // ToolbarActions: Schema → Resolved-Form (analog rowActions).
1444
1499
  // navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
@@ -1488,13 +1543,19 @@ function EntityListBody({
1488
1543
  dispatcherErrorText(result.error, effectiveTranslate),
1489
1544
  );
1490
1545
  }
1491
- // Same refetch as rowActions above.
1492
- await rowsQuery.refetch();
1546
+ await refreshRowsAfterWrite();
1493
1547
  },
1494
1548
  };
1495
1549
  })
1496
1550
  .filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
1497
- }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch, openDrawer]);
1551
+ }, [
1552
+ screen.toolbarActions,
1553
+ effectiveTranslate,
1554
+ nav,
1555
+ dispatcher,
1556
+ refreshRowsAfterWrite,
1557
+ openDrawer,
1558
+ ]);
1498
1559
 
1499
1560
  if (rowsQuery.loading && rowsQuery.data === null) {
1500
1561
  return (
@@ -1718,6 +1779,7 @@ function ProjectionListBody({
1718
1779
  // Validator erzwingt deshalb einen expliziten entityId für
1719
1780
  // projectionList-entity-Targets.
1720
1781
  const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
1782
+ if (id === "") return;
1721
1783
  nav.navigate({ entity: action.entity, id });
1722
1784
  } else if (action.screen !== undefined) {
1723
1785
  const entityId =
@@ -1726,16 +1788,13 @@ function ProjectionListBody({
1726
1788
  screenId: action.screen,
1727
1789
  ...(entityId !== undefined && entityId !== "" && { entityId }),
1728
1790
  });
1791
+ } else {
1792
+ return;
1729
1793
  }
1730
1794
  const params =
1731
1795
  action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1732
1796
  if (params !== undefined) {
1733
- const stringified: Record<string, string | null> = {};
1734
- for (const [k, v] of Object.entries(params)) {
1735
- stringified[k] =
1736
- v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
1737
- }
1738
- nav.setSearchParams(stringified);
1797
+ nav.setSearchParams(stringifyNavParams(params));
1739
1798
  }
1740
1799
  },
1741
1800
  [nav],
@@ -1788,7 +1847,7 @@ function ProjectionListBody({
1788
1847
  );
1789
1848
  }
1790
1849
  // Same refetch as EntityListBody's rowActions above.
1791
- await rowsQuery.refetch();
1850
+ await refetchAfterWrite(rowsQuery.refetch);
1792
1851
  },
1793
1852
  ...(writeVisible !== undefined && {
1794
1853
  isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
@@ -1843,7 +1902,7 @@ function ProjectionListBody({
1843
1902
  );
1844
1903
  }
1845
1904
  // Same refetch as rowActions above.
1846
- await rowsQuery.refetch();
1905
+ await refetchAfterWrite(rowsQuery.refetch);
1847
1906
  },
1848
1907
  });
1849
1908
  }
@@ -2031,16 +2090,7 @@ function ProjectionDetailBody({
2031
2090
  const params =
2032
2091
  action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
2033
2092
  if (params !== undefined) {
2034
- const stringified: Record<string, string | null> = {};
2035
- for (const [k, v] of Object.entries(params)) {
2036
- stringified[k] =
2037
- v === null || v === undefined
2038
- ? null
2039
- : Array.isArray(v)
2040
- ? JSON.stringify(v)
2041
- : String(v);
2042
- }
2043
- nav.setSearchParams(stringified);
2093
+ nav.setSearchParams(stringifyNavParams(params));
2044
2094
  }
2045
2095
  };
2046
2096
  if (action.entity !== undefined) {
@@ -2056,6 +2106,7 @@ function ProjectionDetailBody({
2056
2106
  label: effectiveTranslate(action.label),
2057
2107
  ...(action.style !== undefined && { style: action.style }),
2058
2108
  onPress: () => {
2109
+ if (id === "") return;
2059
2110
  nav.navigate({ entity: targetEntity, id });
2060
2111
  runParams();
2061
2112
  },
@@ -2235,14 +2286,19 @@ function ActionFormBody({
2235
2286
  // Author entscheidet bewusst ob "stay on form" (default) oder
2236
2287
  // "back to list" (typisch bei Create-style Aktionen).
2237
2288
  if (screen.redirect !== undefined) {
2289
+ const targetId = lastSegment(screen.redirect);
2290
+ const target = schema.screens.find((s) => lastSegment(s.id) === targetId);
2238
2291
  const entityId = extractCreatedId(result.data);
2292
+ const carriesId =
2293
+ target !== undefined &&
2294
+ (target.type === "entityEdit" || target.type === "projectionDetail");
2239
2295
  nav.navigate({
2240
- screenId: lastSegment(screen.redirect),
2241
- ...(entityId !== undefined && { entityId }),
2296
+ screenId: targetId,
2297
+ ...(carriesId && entityId !== undefined && { entityId }),
2242
2298
  });
2243
2299
  }
2244
2300
  },
2245
- [nav, screen.redirect, onSuccess],
2301
+ [nav, screen.redirect, onSuccess, schema.screens],
2246
2302
  );
2247
2303
  // Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
2248
2304
  // sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
@@ -34,7 +34,16 @@ const TestDialog: ComponentType<DialogProps> = ({
34
34
  {description !== undefined && (
35
35
  <span data-testid={`${testId}-description`}>{description}</span>
36
36
  )}
37
- <button type="button" data-testid={`${testId}-confirm`} onClick={() => void onConfirm()}>
37
+ <button
38
+ type="button"
39
+ data-testid={`${testId}-confirm`}
40
+ onClick={() => {
41
+ void (async () => {
42
+ await onConfirm();
43
+ onOpenChange(false);
44
+ })();
45
+ }}
46
+ >
38
47
  {confirmLabel ?? "Confirm"}
39
48
  </button>
40
49
  <button type="button" data-testid={`${testId}-cancel`} onClick={() => onOpenChange(false)}>
@@ -98,6 +107,9 @@ describe("RenderEditActionButton", () => {
98
107
  fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive"));
99
108
  fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive-dialog-confirm"));
100
109
  await waitFor(() => expect(pressed).toBe(1));
110
+ await waitFor(() =>
111
+ expect(rtlScreen.queryByTestId("render-edit-action-archive-dialog")).toBeNull(),
112
+ );
101
113
  });
102
114
 
103
115
  test("danger style forces confirm dialog even without confirm text", async () => {
@@ -117,6 +129,10 @@ describe("RenderEditActionButton", () => {
117
129
  expect(rtlScreen.queryByTestId("render-edit-action-delete-dialog-description")).toBeNull();
118
130
  expect(pressed).toBe(0);
119
131
 
132
+ expect(rtlScreen.getByTestId("render-edit-action-delete-dialog-confirm").textContent).toBe(
133
+ "Delete",
134
+ );
135
+
120
136
  fireEvent.click(rtlScreen.getByTestId("render-edit-action-delete-dialog-confirm"));
121
137
  await waitFor(() => expect(pressed).toBe(1));
122
138
  });
@@ -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
  });
@@ -192,6 +192,20 @@ describe("RenderField money round-trip (kumiko-framework#1923)", () => {
192
192
  expect(result.data?.["price"]).toEqual({ amount: 500, currency: "JPY" });
193
193
  });
194
194
 
195
+ test("record currency differs from entity.defaultCurrency (#1930)", () => {
196
+ const entity = buildEntity("EUR");
197
+ let payload: unknown;
198
+ const field = renderMoneyField(entity, { price: { amount: 500, currency: "JPY" } }, (v) => {
199
+ payload = v;
200
+ });
201
+ if (field.kind !== "money") throw new Error("expected money kind");
202
+ // Prefer the value's own currency over entity.defaultCurrency (EUR=2dp).
203
+ expect(field.currency).toBe("JPY");
204
+ expect(field.value).toBe(500);
205
+ field.onChange(500);
206
+ expect(payload).toEqual({ amount: 500, currency: "JPY" });
207
+ });
208
+
195
209
  test("server schema strips an unexpected amountMinor key instead of rejecting the payload", () => {
196
210
  const entity = buildEntity("USD");
197
211
  const result = buildUpdateSchema(entity).safeParse({
@@ -99,4 +99,9 @@ describe("RenderField — App-Locale an FieldRendererOutput durchreichen (fw#218
99
99
  renderUnderLocale("de-DE", livingSpaceField({ format: "number" }, 1234.5));
100
100
  expect(captured?.children).toBe("1.234,5");
101
101
  });
102
+
103
+ test("explicit locale: undefined still falls back to App-Locale (#2332)", () => {
104
+ renderUnderLocale("de-DE", livingSpaceField({ format: "number", locale: undefined }, 1234.5));
105
+ expect(captured?.children).toBe("1.234,5");
106
+ });
102
107
  });
@@ -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,