@cosmicdrift/kumiko-renderer 0.238.0 → 0.240.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.238.0",
3
+ "version": "0.240.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.238.0",
19
- "@cosmicdrift/kumiko-headless": "0.238.0",
18
+ "@cosmicdrift/kumiko-framework": "0.240.0",
19
+ "@cosmicdrift/kumiko-headless": "0.240.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.238.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.240.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { synthesizeProjectionDetailScreen } from "../projection-detail-shim";
4
+
5
+ // Regression for the writeForm extension (fw editable-detail-screens): the
6
+ // hard readOnly:true enforcement in synthesizeProjectionDetailScreen must
7
+ // still apply to every ordinary fields-section, unchanged — a writeForm
8
+ // section is the one deliberate exception (isFieldsEditSection excludes it,
9
+ // same as relatedList/extension), it must pass through editable.
10
+ describe("synthesizeProjectionDetailScreen", () => {
11
+ test("forces readOnly:true on a fields section, but leaves a writeForm section's own readOnly untouched", () => {
12
+ const screen: ProjectionDetailScreenDefinition = {
13
+ id: "order-detail",
14
+ type: "projectionDetail",
15
+ query: "orders:query:order:detail",
16
+ layout: {
17
+ sections: [
18
+ { title: "Basics", fields: [{ field: "name", readOnly: false }] },
19
+ {
20
+ kind: "writeForm",
21
+ title: "Add note",
22
+ fieldDefs: { note: { type: "text" } },
23
+ fields: [{ field: "note", readOnly: false }],
24
+ handler: "orders:write:add-note",
25
+ },
26
+ ],
27
+ },
28
+ };
29
+
30
+ const result = synthesizeProjectionDetailScreen(screen);
31
+
32
+ const [fieldsSection, writeFormSection] = result.layout.sections;
33
+ if (fieldsSection === undefined || !("fields" in fieldsSection) || "kind" in fieldsSection) {
34
+ throw new Error("expected the first section to stay a plain fields section");
35
+ }
36
+ expect(fieldsSection.fields[0]).toMatchObject({ field: "name", readOnly: true });
37
+
38
+ if (writeFormSection === undefined || writeFormSection.kind !== "writeForm") {
39
+ throw new Error("expected the second section to stay kind: writeForm, untouched");
40
+ }
41
+ expect(writeFormSection.fields[0]).toEqual({ field: "note", readOnly: false });
42
+ });
43
+ });
@@ -6,14 +6,11 @@ import type {
6
6
  EntityDefinition,
7
7
  EntityEditScreenDefinition,
8
8
  EntityListScreenDefinition,
9
- IconKey,
10
9
  ListFacetSpec,
11
10
  ProjectionDetailScreenDefinition,
12
11
  ProjectionListScreenDefinition,
13
12
  RowAction,
14
13
  RowActionNavigate,
15
- RowActionWriteHandler,
16
- RowFieldExtractor,
17
14
  ScreenDefinition,
18
15
  ToolbarAction,
19
16
  } from "@cosmicdrift/kumiko-framework/ui-types";
@@ -39,8 +36,6 @@ import { useTranslation } from "../i18n";
39
36
  import {
40
37
  type DataTableFacet,
41
38
  type DataTableRowAction,
42
- type DataTableRowActionMode,
43
- shouldRenderActionsIconOnly,
44
39
  statusToneForValue,
45
40
  usePrimitives,
46
41
  } from "../primitives";
@@ -60,81 +55,20 @@ import {
60
55
  import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
61
56
  import { lastSegment, toKebab } from "./qn";
62
57
  import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
58
+ import {
59
+ buildProjectionRowActions,
60
+ evalRowExtractor,
61
+ isWriteHandlerRowAction,
62
+ refetchAfterWrite,
63
+ resolveActionIcon,
64
+ rowActionModeFor,
65
+ runProjectionRowNavigate,
66
+ stringifyNavParams,
67
+ } from "./row-actions";
63
68
  import { screenAccessAllows } from "./screen-access";
64
69
  import { SecretsEditBody } from "./secrets-edit-body";
65
70
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
66
71
 
67
- function evalRowExtractor(
68
- extractor: RowFieldExtractor,
69
- row: Record<string, unknown>,
70
- ): Record<string, unknown> {
71
- if ("pick" in extractor) {
72
- return Object.fromEntries(extractor.pick.map((f) => [f, row[f]]));
73
- }
74
- return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
75
- }
76
-
77
- function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHandler {
78
- return action.kind === "writeHandler" || action.kind === undefined;
79
- }
80
-
81
- // Part B (fw-ui-defaults): id-derived default icon for actions that never
82
- // declared one — a screen author still gets a recognizable glyph instead of
83
- // a bare label. Checked against the actually registered IconKey vocabulary
84
- // (nav-icon.ts) — no entry for verbs without a matching icon (e.g. "start",
85
- // "pause").
86
- const ACTION_ICON_BY_ID: Readonly<Partial<Record<string, IconKey>>> = {
87
- delete: "trash",
88
- edit: "pencil",
89
- create: "plus",
90
- new: "plus",
91
- add: "plus",
92
- view: "eye",
93
- open: "eye",
94
- cancel: "x",
95
- reject: "x",
96
- complete: "check",
97
- resolve: "check",
98
- approve: "check",
99
- archive: "archive",
100
- publish: "upload",
101
- duplicate: "copy",
102
- copy: "copy",
103
- download: "download",
104
- refresh: "refresh",
105
- retry: "refresh",
106
- settings: "settings",
107
- share: "share",
108
- send: "send",
109
- };
110
-
111
- // Ids are kebab-case (RowAction.id doc) — a compound id whose full form has
112
- // no entry falls back to its last segment ("order-ship" -> "ship").
113
- function kebabLastSegment(id: string): string {
114
- const idx = id.lastIndexOf("-");
115
- return idx === -1 ? id : id.slice(idx + 1);
116
- }
117
-
118
- // Resolution order: author-declared `icon` wins, then the id-derived
119
- // default (full id, then its last kebab segment). `declared` is `undefined`
120
- // for ToolbarAction, which has no author-facing icon field.
121
- function resolveActionIcon(id: string, declared?: IconKey): IconKey | undefined {
122
- if (declared !== undefined) return declared;
123
- return ACTION_ICON_BY_ID[id] ?? ACTION_ICON_BY_ID[kebabLastSegment(id)];
124
- }
125
-
126
- // Row-action column mode for a resolved action set: a group where every
127
- // member carries an icon renders inline so `shouldRenderActionsIconOnly`
128
- // can collapse it to icon-only buttons (fw#2580). Anything else keeps the
129
- // DataTable's adaptive default (kebab past two actions) — inline text
130
- // buttons for an icon-less group are the very thing the collapse avoids.
131
- function rowActionModeFor(
132
- actions: readonly DataTableRowAction[] | undefined,
133
- ): DataTableRowActionMode | undefined {
134
- if (actions === undefined || !shouldRenderActionsIconOnly(actions)) return undefined;
135
- return "inline";
136
- }
137
-
138
72
  // KumikoScreen picks up a ScreenDefinition from the schema by qn and
139
73
  // routes it to the right renderer based on `screen.type`. Command
140
74
  // qualification (`<feature>:write:<entity>:create` etc.) happens here
@@ -1106,26 +1040,10 @@ function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[
1106
1040
  // passes through any field present in entity.fields, matching its
1107
1041
  // pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
1108
1042
 
1109
- function stringifyNavParams(params: Record<string, unknown>): Record<string, string | null> {
1110
- const out: Record<string, string | null> = {};
1111
- for (const [k, v] of Object.entries(params)) {
1112
- out[k] =
1113
- v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
1114
- }
1115
- return out;
1116
- }
1117
-
1118
1043
  function isFacetI18nKey(label: string): boolean {
1119
1044
  return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
1120
1045
  }
1121
1046
 
1122
- async function refetchAfterWrite(refetch: () => Promise<unknown>): Promise<void> {
1123
- await refetch().catch((err: unknown) => {
1124
- // biome-ignore lint/suspicious/noConsole: refetch must not poison the write-action error path
1125
- console.error("kumiko-screen: refetch after write action failed", err);
1126
- });
1127
- }
1128
-
1129
1047
  function buildFilterPayload(
1130
1048
  urlFilters: Readonly<Record<string, readonly string[]>>,
1131
1049
  typeOf: (field: string) => string | undefined,
@@ -1953,97 +1871,31 @@ function ProjectionListBody({
1953
1871
 
1954
1872
  const filterFacets = useMemo<DataTableFacet[]>(() => buildFilterFacets(facetSpecs), [facetSpecs]);
1955
1873
 
1874
+ // Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
1875
+ // resolution happens in the NavApi impl. Unlike there: NO row["id"]
1876
+ // fallback — projectionList rows come from an arbitrary query projection
1877
+ // with no guaranteed "id" field. The boot validator therefore enforces an
1878
+ // explicit entityId for projectionList entity targets. Same helper
1879
+ // relatedList's rowActions reuse (related-list-section.tsx) — a
1880
+ // projectionDetail relatedList row has the identical "no guaranteed id"
1881
+ // shape.
1956
1882
  const runNavigate = useCallback(
1957
- (action: RowActionNavigate, row: ListRowViewModel) => {
1958
- if (action.entity !== undefined) {
1959
- // Entity-Targets (fw#2228) — siehe EntityListBody.runNavigate für die
1960
- // Begründung, warum die Auflösung in der NavApi-Impl passiert. Anders
1961
- // als dort: KEIN row["id"]-Fallback — projectionList-Rows kommen aus
1962
- // einer beliebigen Query-Projection ohne garantiertes "id"-Feld
1963
- // (gleiche Begründung wie beim screen-Target unten). Der Boot-
1964
- // Validator erzwingt deshalb einen expliziten entityId für
1965
- // projectionList-entity-Targets.
1966
- const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
1967
- if (id === "") return;
1968
- nav.navigate({ entity: action.entity, id });
1969
- } else if (action.screen !== undefined) {
1970
- const entityId =
1971
- action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1972
- nav.navigate({
1973
- screenId: action.screen,
1974
- ...(entityId !== undefined && entityId !== "" && { entityId }),
1975
- });
1976
- } else {
1977
- return;
1978
- }
1979
- const params =
1980
- action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1981
- if (params !== undefined) {
1982
- nav.setSearchParams(stringifyNavParams(params));
1983
- }
1984
- },
1883
+ (action: RowActionNavigate, row: ListRowViewModel) =>
1884
+ runProjectionRowNavigate(nav, action, row),
1985
1885
  [nav],
1986
1886
  );
1987
1887
 
1988
- const rowActions = useMemo((): readonly DataTableRowAction[] | undefined => {
1989
- if (screen.rowActions === undefined) return undefined;
1990
- const out: DataTableRowAction[] = [];
1991
- for (const action of screen.rowActions) {
1992
- if (action.kind === "navigate") {
1993
- const navigateAction = action;
1994
- const visible = action.visible;
1995
- const actionIcon = resolveActionIcon(action.id, action.icon);
1996
- out.push({
1997
- id: action.id,
1998
- label: effectiveTranslate(action.label),
1999
- ...(action.style !== undefined && { style: action.style }),
2000
- ...(actionIcon !== undefined && { icon: actionIcon }),
2001
- onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
2002
- ...(visible !== undefined && {
2003
- isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
2004
- }),
2005
- });
2006
- continue;
2007
- }
2008
- // writeHandler (default-kind) — gleicher Dispatch-Pfad wie entityList:
2009
- // Failure-Result MUSS zum Error werden (sonst schließt der Confirm-
2010
- // Dialog kommentarlos).
2011
- if (dispatcher === undefined) continue;
2012
- const writeAction = action;
2013
- const writeVisible = writeAction.visible;
2014
- out.push({
2015
- id: writeAction.id,
2016
- label: effectiveTranslate(writeAction.label),
2017
- ...(writeAction.style !== undefined && { style: writeAction.style }),
2018
- icon: resolveActionIcon(writeAction.id, writeAction.icon),
2019
- ...(writeAction.confirm !== undefined && {
2020
- confirm: effectiveTranslate(writeAction.confirm),
2021
- }),
2022
- ...(writeAction.confirmLabel !== undefined && {
2023
- confirmLabel: effectiveTranslate(writeAction.confirmLabel),
2024
- }),
2025
- onTrigger: async (row: ListRowViewModel) => {
2026
- const payload =
2027
- writeAction.payload !== undefined
2028
- ? evalRowExtractor(writeAction.payload, row.values)
2029
- : { id: row.values["id"] };
2030
- const result = await dispatcher.write(writeAction.handler, payload);
2031
- if (!result.isSuccess) {
2032
- throw new WriteFailedError(
2033
- result.error,
2034
- dispatcherErrorText(result.error, effectiveTranslate),
2035
- );
2036
- }
2037
- // Same refetch as EntityListBody's rowActions above.
2038
- await refetchAfterWrite(rowsQuery.refetch);
2039
- },
2040
- ...(writeVisible !== undefined && {
2041
- isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
2042
- }),
2043
- });
2044
- }
2045
- return out.length > 0 ? out : undefined;
2046
- }, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher, rowsQuery.refetch]);
1888
+ const rowActions = useMemo(
1889
+ () =>
1890
+ buildProjectionRowActions({
1891
+ rowActions: screen.rowActions,
1892
+ translate: effectiveTranslate,
1893
+ dispatcher,
1894
+ nav,
1895
+ refetch: rowsQuery.refetch,
1896
+ }),
1897
+ [screen.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch],
1898
+ );
2047
1899
 
2048
1900
  // Same icon-only collapse as entityList (fw#2580) — projectionList rows go
2049
1901
  // through the identical RenderList/DataTable path.
@@ -2255,6 +2107,18 @@ function ProjectionDetailBody({
2255
2107
  entityId !== undefined ? { [idParam]: entityId } : {},
2256
2108
  );
2257
2109
 
2110
+ // A writeForm section's handler creates a new record (see EditWriteFormSection's
2111
+ // doc) — both the outer record (header/metrics/other sections) and a sibling
2112
+ // relatedList section's own independently-fetched rows must reflect it. A
2113
+ // query refetch alone only refreshes `record` below; bumping this into
2114
+ // <RenderEdit>'s `key` forces a full remount so RelatedListSection's own
2115
+ // useQuery call re-runs too (it has no `live` subscription of its own).
2116
+ const [reloadNonce, setReloadNonce] = useState(0);
2117
+ const reloadDetail = useCallback(async () => {
2118
+ await detailQuery.refetch();
2119
+ setReloadNonce((n) => n + 1);
2120
+ }, [detailQuery.refetch]);
2121
+
2258
2122
  // Default edit action (fw#2166): resolved cross-feature over ALL mounted
2259
2123
  // features, not just this feature's own schema — detailFor itself is
2260
2124
  // resolved cross-feature by the boot-validator (detail-screens.ts), and
@@ -2528,12 +2392,14 @@ function ProjectionDetailBody({
2528
2392
  );
2529
2393
  return (
2530
2394
  <RenderEdit
2395
+ key={`${entityId}:${reloadNonce}`}
2531
2396
  screen={detailScreen}
2532
2397
  entity={entity}
2533
2398
  featureName={schema.featureName}
2534
2399
  initial={record as FormValues}
2535
2400
  entityId={entityId}
2536
2401
  customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
2402
+ onReload={reloadDetail}
2537
2403
  {...(headerActions !== undefined && { actions: headerActions })}
2538
2404
  {...(translate !== undefined && { translate })}
2539
2405
  {...(hasTabs && { hideSectionTitles: true })}
@@ -0,0 +1,201 @@
1
+ import type {
2
+ IconKey,
3
+ RowAction,
4
+ RowActionNavigate,
5
+ RowActionWriteHandler,
6
+ RowFieldExtractor,
7
+ } from "@cosmicdrift/kumiko-framework/ui-types";
8
+ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
9
+ import type { Dispatcher, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
10
+ import type { DataTableRowAction, DataTableRowActionMode } from "../primitives";
11
+ import { shouldRenderActionsIconOnly } from "../primitives";
12
+ import type { NavApi } from "./nav";
13
+ import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
14
+
15
+ export function evalRowExtractor(
16
+ extractor: RowFieldExtractor,
17
+ row: Record<string, unknown>,
18
+ ): Record<string, unknown> {
19
+ if ("pick" in extractor) {
20
+ return Object.fromEntries(extractor.pick.map((f) => [f, row[f]]));
21
+ }
22
+ return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
23
+ }
24
+
25
+ export function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHandler {
26
+ return action.kind === "writeHandler" || action.kind === undefined;
27
+ }
28
+
29
+ // Part B (fw-ui-defaults): id-derived default icon for actions that never
30
+ // declared one — a screen author still gets a recognizable glyph instead of
31
+ // a bare label. Checked against the actually registered IconKey vocabulary
32
+ // (nav-icon.ts) — no entry for verbs without a matching icon (e.g. "start",
33
+ // "pause").
34
+ const ACTION_ICON_BY_ID: Readonly<Partial<Record<string, IconKey>>> = {
35
+ delete: "trash",
36
+ edit: "pencil",
37
+ create: "plus",
38
+ new: "plus",
39
+ add: "plus",
40
+ view: "eye",
41
+ open: "eye",
42
+ cancel: "x",
43
+ reject: "x",
44
+ complete: "check",
45
+ resolve: "check",
46
+ approve: "check",
47
+ archive: "archive",
48
+ publish: "upload",
49
+ duplicate: "copy",
50
+ copy: "copy",
51
+ download: "download",
52
+ refresh: "refresh",
53
+ retry: "refresh",
54
+ settings: "settings",
55
+ share: "share",
56
+ send: "send",
57
+ };
58
+
59
+ // Ids are kebab-case (RowAction.id doc) — a compound id whose full form has
60
+ // no entry falls back to its last segment ("order-ship" -> "ship").
61
+ function kebabLastSegment(id: string): string {
62
+ const idx = id.lastIndexOf("-");
63
+ return idx === -1 ? id : id.slice(idx + 1);
64
+ }
65
+
66
+ // Resolution order: author-declared `icon` wins, then the id-derived
67
+ // default (full id, then its last kebab segment). `declared` is `undefined`
68
+ // for ToolbarAction, which has no author-facing icon field.
69
+ export function resolveActionIcon(id: string, declared?: IconKey): IconKey | undefined {
70
+ if (declared !== undefined) return declared;
71
+ return ACTION_ICON_BY_ID[id] ?? ACTION_ICON_BY_ID[kebabLastSegment(id)];
72
+ }
73
+
74
+ // Row-action column mode for a resolved action set: a group where every
75
+ // member carries an icon renders inline so `shouldRenderActionsIconOnly`
76
+ // can collapse it to icon-only buttons (fw#2580). Anything else keeps the
77
+ // DataTable's adaptive default (kebab past two actions) — inline text
78
+ // buttons for an icon-less group are the very thing the collapse avoids.
79
+ export function rowActionModeFor(
80
+ actions: readonly DataTableRowAction[] | undefined,
81
+ ): DataTableRowActionMode | undefined {
82
+ if (actions === undefined || !shouldRenderActionsIconOnly(actions)) return undefined;
83
+ return "inline";
84
+ }
85
+
86
+ export function stringifyNavParams(params: Record<string, unknown>): Record<string, string | null> {
87
+ const out: Record<string, string | null> = {};
88
+ for (const [k, v] of Object.entries(params)) {
89
+ out[k] =
90
+ v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ export async function refetchAfterWrite(refetch: () => Promise<unknown>): Promise<void> {
96
+ await refetch().catch((err: unknown) => {
97
+ // biome-ignore lint/suspicious/noConsole: refetch must not poison the write-action error path
98
+ console.error("kumiko-screen: refetch after write action failed", err);
99
+ });
100
+ }
101
+
102
+ // Navigate execution for query-driven rows (projectionList, and relatedList
103
+ // — both have no guaranteed "id" field, unlike entityList's rows which back
104
+ // a real entity). No same-entity row["id"] fallback (see EntityListBody's
105
+ // own runNavigate for that variant, which stays separate — entityList's
106
+ // fallback needs `screen.entity`, which neither projectionList nor
107
+ // relatedList has).
108
+ export function runProjectionRowNavigate(
109
+ nav: NavApi,
110
+ action: RowActionNavigate,
111
+ row: ListRowViewModel,
112
+ ): void {
113
+ if (action.entity !== undefined) {
114
+ const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
115
+ // skip: no entityId column on this row — nothing to navigate to.
116
+ if (id === "") return;
117
+ nav.navigate({ entity: action.entity, id });
118
+ } else if (action.screen !== undefined) {
119
+ const entityId =
120
+ action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
121
+ nav.navigate({
122
+ screenId: action.screen,
123
+ ...(entityId !== undefined && entityId !== "" && { entityId }),
124
+ });
125
+ } else {
126
+ // skip: neither entity nor screen set — the boot-validator rejects this
127
+ // shape (resolveRowActionNavigateTarget), so this only guards types.
128
+ return;
129
+ }
130
+ const params =
131
+ action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
132
+ if (params !== undefined) {
133
+ nav.setSearchParams(stringifyNavParams(params));
134
+ }
135
+ }
136
+
137
+ // Builds the DataTable-ready row-action set for a query-driven row source
138
+ // (projectionList, relatedList) — navigate dispatches through
139
+ // runProjectionRowNavigate, writeHandler dispatches through the shared
140
+ // Dispatcher and refetches `refetch` on success. Single implementation so
141
+ // projectionList's rowActions and a projectionDetail relatedList section's
142
+ // rowActions can't drift apart (fw editable-detail-screens).
143
+ export function buildProjectionRowActions(options: {
144
+ readonly rowActions: readonly RowAction[] | undefined;
145
+ readonly translate: Translate;
146
+ readonly dispatcher: Dispatcher | undefined;
147
+ readonly nav: NavApi;
148
+ readonly refetch: () => Promise<unknown>;
149
+ }): readonly DataTableRowAction[] | undefined {
150
+ const { rowActions, translate, dispatcher, nav, refetch } = options;
151
+ if (rowActions === undefined) return undefined;
152
+ const out: DataTableRowAction[] = [];
153
+ for (const action of rowActions) {
154
+ if (action.kind === "navigate") {
155
+ const navigateAction = action;
156
+ const visible = action.visible;
157
+ const actionIcon = resolveActionIcon(action.id, action.icon);
158
+ out.push({
159
+ id: action.id,
160
+ label: translate(action.label),
161
+ ...(action.style !== undefined && { style: action.style }),
162
+ ...(actionIcon !== undefined && { icon: actionIcon }),
163
+ onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, navigateAction, row),
164
+ ...(visible !== undefined && {
165
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
166
+ }),
167
+ });
168
+ continue;
169
+ }
170
+ // writeHandler (default-kind) — a swallowed failure result must become a
171
+ // thrown error (fw prod-bug 2026-06-07), same as every other write path.
172
+ if (dispatcher === undefined) continue;
173
+ const writeAction = action;
174
+ const writeVisible = writeAction.visible;
175
+ out.push({
176
+ id: writeAction.id,
177
+ label: translate(writeAction.label),
178
+ ...(writeAction.style !== undefined && { style: writeAction.style }),
179
+ icon: resolveActionIcon(writeAction.id, writeAction.icon),
180
+ ...(writeAction.confirm !== undefined && { confirm: translate(writeAction.confirm) }),
181
+ ...(writeAction.confirmLabel !== undefined && {
182
+ confirmLabel: translate(writeAction.confirmLabel),
183
+ }),
184
+ onTrigger: async (row: ListRowViewModel) => {
185
+ const payload =
186
+ writeAction.payload !== undefined
187
+ ? evalRowExtractor(writeAction.payload, row.values)
188
+ : { id: row.values["id"] };
189
+ const result = await dispatcher.write(writeAction.handler, payload);
190
+ if (!result.isSuccess) {
191
+ throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
192
+ }
193
+ await refetchAfterWrite(refetch);
194
+ },
195
+ ...(writeVisible !== undefined && {
196
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
197
+ }),
198
+ });
199
+ }
200
+ return out.length > 0 ? out : undefined;
201
+ }