@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 +4 -4
- package/src/app/__tests__/projection-detail-shim.test.ts +43 -0
- package/src/app/kumiko-screen.tsx +45 -179
- package/src/app/row-actions.ts +201 -0
- package/src/components/__tests__/related-list-section.test.tsx +163 -0
- package/src/components/__tests__/render-edit-logic.test.ts +14 -0
- package/src/components/__tests__/write-form-section.test.tsx +205 -0
- package/src/components/grid-cell-for-field.tsx +60 -0
- package/src/components/related-list-section.tsx +36 -1
- package/src/components/render-edit-logic.ts +9 -4
- package/src/components/render-edit.tsx +24 -65
- package/src/components/write-form-section.tsx +155 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "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.
|
|
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
|
-
|
|
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(
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
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
|
+
}
|