@cosmicdrift/kumiko-renderer 0.233.0 → 0.235.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 +6 -4
- package/src/__tests__/i18n.test.tsx +39 -0
- package/src/app/__tests__/entity-list-row-action-icon.test.tsx +169 -0
- package/src/app/kumiko-screen.tsx +255 -50
- package/src/app/secrets-edit-body.tsx +190 -0
- package/src/components/__tests__/render-field-icon-derivation.test.tsx +119 -0
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +146 -0
- package/src/components/related-list-section.tsx +29 -19
- package/src/components/render-edit-action-button.tsx +8 -1
- package/src/components/render-edit-types.ts +5 -0
- package/src/components/render-edit.tsx +26 -4
- package/src/components/render-field.tsx +47 -5
- package/src/components/render-list.tsx +27 -2
- package/src/i18n.tsx +4 -4
- package/src/index.ts +6 -1
- package/src/primitives.tsx +63 -1
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
EntityDefinition,
|
|
7
7
|
EntityEditScreenDefinition,
|
|
8
8
|
EntityListScreenDefinition,
|
|
9
|
+
IconKey,
|
|
9
10
|
ListFacetSpec,
|
|
10
11
|
ProjectionDetailScreenDefinition,
|
|
11
12
|
ProjectionListScreenDefinition,
|
|
@@ -35,7 +36,14 @@ import { useUserRoles } from "../context/user-roles-context";
|
|
|
35
36
|
import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
|
|
36
37
|
import { useQuery } from "../hooks/use-query";
|
|
37
38
|
import { useTranslation } from "../i18n";
|
|
38
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
type DataTableFacet,
|
|
41
|
+
type DataTableRowAction,
|
|
42
|
+
type DataTableRowActionMode,
|
|
43
|
+
shouldRenderActionsIconOnly,
|
|
44
|
+
statusToneForValue,
|
|
45
|
+
usePrimitives,
|
|
46
|
+
} from "../primitives";
|
|
39
47
|
import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
|
|
40
48
|
import { useAppFeatures } from "./app-features-context";
|
|
41
49
|
import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
|
|
@@ -53,6 +61,7 @@ import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projec
|
|
|
53
61
|
import { lastSegment, toKebab } from "./qn";
|
|
54
62
|
import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
|
|
55
63
|
import { screenAccessAllows } from "./screen-access";
|
|
64
|
+
import { SecretsEditBody } from "./secrets-edit-body";
|
|
56
65
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
57
66
|
|
|
58
67
|
function evalRowExtractor(
|
|
@@ -69,6 +78,63 @@ function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHan
|
|
|
69
78
|
return action.kind === "writeHandler" || action.kind === undefined;
|
|
70
79
|
}
|
|
71
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
|
+
|
|
72
138
|
// KumikoScreen picks up a ScreenDefinition from the schema by qn and
|
|
73
139
|
// routes it to the right renderer based on `screen.type`. Command
|
|
74
140
|
// qualification (`<feature>:write:<entity>:create` etc.) happens here
|
|
@@ -197,6 +263,8 @@ export function KumikoScreen({
|
|
|
197
263
|
return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
|
|
198
264
|
case "configEdit":
|
|
199
265
|
return <ConfigEditBody schema={schema} screen={screen} translate={translate} />;
|
|
266
|
+
case "secretsEdit":
|
|
267
|
+
return <SecretsEditBody screen={screen} translate={translate} />;
|
|
200
268
|
case "custom":
|
|
201
269
|
return <CustomScreenBody screenId={screen.id} featureName={schema.featureName} />;
|
|
202
270
|
}
|
|
@@ -544,6 +612,10 @@ function EntityEditCreateBody({
|
|
|
544
612
|
},
|
|
545
613
|
[nav, screen.redirect, navigateToList, onSaved],
|
|
546
614
|
);
|
|
615
|
+
// Deliberately no `actions` prop here: `screen.actions` targets an
|
|
616
|
+
// EXISTING record (publish/archive/duplicate and friends), which the
|
|
617
|
+
// create branch has none of yet — see EntityEditUpdateForm for the
|
|
618
|
+
// wired-up counterpart.
|
|
547
619
|
return (
|
|
548
620
|
<RenderEdit
|
|
549
621
|
screen={screen}
|
|
@@ -712,7 +784,99 @@ function EntityEditUpdateForm({
|
|
|
712
784
|
|
|
713
785
|
const nav = useNav();
|
|
714
786
|
const dispatcher = useDispatcher();
|
|
787
|
+
const t = useTranslation();
|
|
788
|
+
const effectiveTranslate = translate ?? t;
|
|
715
789
|
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
790
|
+
// Header action buttons (fw entityEdit-actions) — same shape/dispatch
|
|
791
|
+
// pattern as ProjectionDetailBody.headerActions above (the edited record
|
|
792
|
+
// stands in for the "row"), minus the cross-feature defaultEditAction
|
|
793
|
+
// lookup that doesn't apply here (this screen already IS the edit form).
|
|
794
|
+
const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
|
|
795
|
+
const out: RenderEditAction[] = [];
|
|
796
|
+
for (const action of screen.actions ?? []) {
|
|
797
|
+
if (action.visible !== undefined && !evalFieldCondition(action.visible, record)) {
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (action.kind === "navigate") {
|
|
801
|
+
const runParams = (): void => {
|
|
802
|
+
const params =
|
|
803
|
+
action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
|
|
804
|
+
if (params !== undefined) {
|
|
805
|
+
nav.setSearchParams(stringifyNavParams(params));
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
809
|
+
if (action.entity !== undefined) {
|
|
810
|
+
const targetEntity = action.entity;
|
|
811
|
+
const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
|
|
812
|
+
out.push({
|
|
813
|
+
id: action.id,
|
|
814
|
+
label: effectiveTranslate(action.label),
|
|
815
|
+
...(action.style !== undefined && { style: action.style }),
|
|
816
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
817
|
+
onPress: () => {
|
|
818
|
+
if (id === "") return;
|
|
819
|
+
nav.navigate({ entity: targetEntity, id });
|
|
820
|
+
runParams();
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
} else if (action.screen !== undefined) {
|
|
824
|
+
// Default entityId for a screen-target: the currently edited
|
|
825
|
+
// record's own id — an entityEdit header action has no other
|
|
826
|
+
// "row" to derive one from (unlike ProjectionDetailBody, which
|
|
827
|
+
// needs the same-entity detailFor lookup instead).
|
|
828
|
+
const explicit =
|
|
829
|
+
action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
|
|
830
|
+
const navEntityId = explicit ?? entityId;
|
|
831
|
+
const targetScreen = action.screen;
|
|
832
|
+
out.push({
|
|
833
|
+
id: action.id,
|
|
834
|
+
label: effectiveTranslate(action.label),
|
|
835
|
+
...(action.style !== undefined && { style: action.style }),
|
|
836
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
837
|
+
onPress: () => {
|
|
838
|
+
nav.navigate({
|
|
839
|
+
screenId: targetScreen,
|
|
840
|
+
...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
|
|
841
|
+
});
|
|
842
|
+
runParams();
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
// writeHandler — same dispatch/reload/failure-surfacing pattern as
|
|
849
|
+
// ProjectionDetailBody's headerActions above.
|
|
850
|
+
const writeAction = action;
|
|
851
|
+
out.push({
|
|
852
|
+
id: writeAction.id,
|
|
853
|
+
label: effectiveTranslate(writeAction.label),
|
|
854
|
+
...(writeAction.style !== undefined && { style: writeAction.style }),
|
|
855
|
+
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
856
|
+
...(writeAction.confirm !== undefined && {
|
|
857
|
+
confirm: effectiveTranslate(writeAction.confirm),
|
|
858
|
+
}),
|
|
859
|
+
...(writeAction.confirmLabel !== undefined && {
|
|
860
|
+
confirmLabel: effectiveTranslate(writeAction.confirmLabel),
|
|
861
|
+
}),
|
|
862
|
+
onPress: async () => {
|
|
863
|
+
const payload =
|
|
864
|
+
writeAction.payload !== undefined
|
|
865
|
+
? evalRowExtractor(writeAction.payload, record)
|
|
866
|
+
: { id: entityId };
|
|
867
|
+
const result = await dispatcher.write(writeAction.handler, payload);
|
|
868
|
+
if (!result.isSuccess) {
|
|
869
|
+
throw new WriteFailedError(
|
|
870
|
+
result.error,
|
|
871
|
+
dispatcherErrorText(result.error, effectiveTranslate),
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
await onReload();
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
return out.length > 0 ? out : undefined;
|
|
879
|
+
}, [screen.actions, effectiveTranslate, nav, dispatcher, record, entityId, onReload]);
|
|
716
880
|
const handleSubmitted = useCallback(
|
|
717
881
|
(result: SubmitResult<unknown>) => {
|
|
718
882
|
if (!result.isSuccess) return;
|
|
@@ -760,6 +924,7 @@ function EntityEditUpdateForm({
|
|
|
760
924
|
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
761
925
|
{...(translate !== undefined && { translate })}
|
|
762
926
|
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
927
|
+
{...(headerActions !== undefined && { actions: headerActions })}
|
|
763
928
|
/>
|
|
764
929
|
);
|
|
765
930
|
}
|
|
@@ -1450,10 +1615,12 @@ function EntityListBody({
|
|
|
1450
1615
|
if (action.kind === "navigate") {
|
|
1451
1616
|
const navigateAction = action;
|
|
1452
1617
|
const actionVisible = action.visible;
|
|
1618
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
1453
1619
|
return {
|
|
1454
1620
|
id: action.id,
|
|
1455
1621
|
label: effectiveTranslate(action.label),
|
|
1456
1622
|
...(action.style !== undefined && { style: action.style }),
|
|
1623
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1457
1624
|
onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
|
|
1458
1625
|
...(actionVisible !== undefined && {
|
|
1459
1626
|
isVisible: (row: ListRowViewModel) => evalFieldCondition(actionVisible, row.values),
|
|
@@ -1468,6 +1635,7 @@ function EntityListBody({
|
|
|
1468
1635
|
id: writeAction.id,
|
|
1469
1636
|
label: effectiveTranslate(writeAction.label),
|
|
1470
1637
|
style: writeAction.style,
|
|
1638
|
+
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
1471
1639
|
confirm:
|
|
1472
1640
|
writeAction.confirm !== undefined ? effectiveTranslate(writeAction.confirm) : undefined,
|
|
1473
1641
|
confirmLabel:
|
|
@@ -1501,6 +1669,12 @@ function EntityListBody({
|
|
|
1501
1669
|
.filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
|
|
1502
1670
|
}, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, refreshRowsAfterWrite]);
|
|
1503
1671
|
|
|
1672
|
+
// Row actions that all resolve an icon render inline and collapse to
|
|
1673
|
+
// icon-only (fw#2580) — the adaptive default would bury more than two of
|
|
1674
|
+
// them in a kebab menu. A group with an icon-less member stays adaptive so
|
|
1675
|
+
// it never degrades into wall-to-wall text buttons.
|
|
1676
|
+
const rowActionMode = rowActionModeFor(rowActions);
|
|
1677
|
+
|
|
1504
1678
|
// ToolbarActions: Schema → Resolved-Form (analog rowActions).
|
|
1505
1679
|
// navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
|
|
1506
1680
|
// → dispatcher.write(handler, payload?()). KumikoScreen kennt schon
|
|
@@ -1509,11 +1683,13 @@ function EntityListBody({
|
|
|
1509
1683
|
if (screen.toolbarActions === undefined) return undefined;
|
|
1510
1684
|
return screen.toolbarActions
|
|
1511
1685
|
.map((action: ToolbarAction): ToolbarActionButton | null => {
|
|
1686
|
+
const actionIcon = resolveActionIcon(action.id);
|
|
1512
1687
|
if (action.kind === "navigate") {
|
|
1513
1688
|
return {
|
|
1514
1689
|
id: action.id,
|
|
1515
1690
|
label: effectiveTranslate(action.label),
|
|
1516
1691
|
...(action.style !== undefined && { style: action.style }),
|
|
1692
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1517
1693
|
onTrigger: () => nav.navigate({ screenId: action.screen }),
|
|
1518
1694
|
};
|
|
1519
1695
|
}
|
|
@@ -1522,6 +1698,7 @@ function EntityListBody({
|
|
|
1522
1698
|
id: action.id,
|
|
1523
1699
|
label: effectiveTranslate(action.label),
|
|
1524
1700
|
...(action.style !== undefined && { style: action.style }),
|
|
1701
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1525
1702
|
onTrigger: () => openDrawer(action),
|
|
1526
1703
|
};
|
|
1527
1704
|
}
|
|
@@ -1533,6 +1710,7 @@ function EntityListBody({
|
|
|
1533
1710
|
id: action.id,
|
|
1534
1711
|
label: effectiveTranslate(action.label),
|
|
1535
1712
|
...(action.style !== undefined && { style: action.style }),
|
|
1713
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1536
1714
|
...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
|
|
1537
1715
|
...(action.confirmLabel !== undefined && {
|
|
1538
1716
|
confirmLabel: effectiveTranslate(action.confirmLabel),
|
|
@@ -1638,6 +1816,7 @@ function EntityListBody({
|
|
|
1638
1816
|
onSortChange={urlState.setSort}
|
|
1639
1817
|
{...(pager !== undefined && { pager })}
|
|
1640
1818
|
{...(rowActions !== undefined && { rowActions })}
|
|
1819
|
+
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
1641
1820
|
{...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
|
|
1642
1821
|
{...(useInfinite && {
|
|
1643
1822
|
onReachEnd: loadMore,
|
|
@@ -1813,10 +1992,12 @@ function ProjectionListBody({
|
|
|
1813
1992
|
if (action.kind === "navigate") {
|
|
1814
1993
|
const navigateAction = action;
|
|
1815
1994
|
const visible = action.visible;
|
|
1995
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
1816
1996
|
out.push({
|
|
1817
1997
|
id: action.id,
|
|
1818
1998
|
label: effectiveTranslate(action.label),
|
|
1819
1999
|
...(action.style !== undefined && { style: action.style }),
|
|
2000
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1820
2001
|
onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
|
|
1821
2002
|
...(visible !== undefined && {
|
|
1822
2003
|
isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
|
|
@@ -1834,6 +2015,7 @@ function ProjectionListBody({
|
|
|
1834
2015
|
id: writeAction.id,
|
|
1835
2016
|
label: effectiveTranslate(writeAction.label),
|
|
1836
2017
|
...(writeAction.style !== undefined && { style: writeAction.style }),
|
|
2018
|
+
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
1837
2019
|
...(writeAction.confirm !== undefined && {
|
|
1838
2020
|
confirm: effectiveTranslate(writeAction.confirm),
|
|
1839
2021
|
}),
|
|
@@ -1863,16 +2045,22 @@ function ProjectionListBody({
|
|
|
1863
2045
|
return out.length > 0 ? out : undefined;
|
|
1864
2046
|
}, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher, rowsQuery.refetch]);
|
|
1865
2047
|
|
|
2048
|
+
// Same icon-only collapse as entityList (fw#2580) — projectionList rows go
|
|
2049
|
+
// through the identical RenderList/DataTable path.
|
|
2050
|
+
const rowActionMode = rowActionModeFor(rowActions);
|
|
2051
|
+
|
|
1866
2052
|
const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
|
|
1867
2053
|
if (screen.toolbarActions === undefined) return undefined;
|
|
1868
2054
|
const out: ToolbarActionButton[] = [];
|
|
1869
2055
|
for (const action of screen.toolbarActions) {
|
|
2056
|
+
const actionIcon = resolveActionIcon(action.id);
|
|
1870
2057
|
if (action.kind === "navigate") {
|
|
1871
2058
|
const target = action.screen;
|
|
1872
2059
|
out.push({
|
|
1873
2060
|
id: action.id,
|
|
1874
2061
|
label: effectiveTranslate(action.label),
|
|
1875
2062
|
...(action.style !== undefined && { style: action.style }),
|
|
2063
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1876
2064
|
onTrigger: () => nav.navigate({ screenId: target }),
|
|
1877
2065
|
});
|
|
1878
2066
|
continue;
|
|
@@ -1882,6 +2070,7 @@ function ProjectionListBody({
|
|
|
1882
2070
|
id: action.id,
|
|
1883
2071
|
label: effectiveTranslate(action.label),
|
|
1884
2072
|
...(action.style !== undefined && { style: action.style }),
|
|
2073
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1885
2074
|
onTrigger: () => openDrawer(action),
|
|
1886
2075
|
});
|
|
1887
2076
|
continue;
|
|
@@ -1892,6 +2081,7 @@ function ProjectionListBody({
|
|
|
1892
2081
|
id: action.id,
|
|
1893
2082
|
label: effectiveTranslate(action.label),
|
|
1894
2083
|
...(action.style !== undefined && { style: action.style }),
|
|
2084
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1895
2085
|
...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
|
|
1896
2086
|
...(action.confirmLabel !== undefined && {
|
|
1897
2087
|
confirmLabel: effectiveTranslate(action.confirmLabel),
|
|
@@ -1977,6 +2167,7 @@ function ProjectionListBody({
|
|
|
1977
2167
|
onSortChange={urlState.setSort}
|
|
1978
2168
|
{...(pager !== undefined && { pager })}
|
|
1979
2169
|
{...(rowActions !== undefined && { rowActions })}
|
|
2170
|
+
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
1980
2171
|
{...(toolbarActions !== undefined && { toolbarActions })}
|
|
1981
2172
|
{...(translate !== undefined && { translate })}
|
|
1982
2173
|
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
@@ -2027,22 +2218,22 @@ function ProjectionDetailBody({
|
|
|
2027
2218
|
readonly translate?: Translate;
|
|
2028
2219
|
readonly entityId?: string;
|
|
2029
2220
|
}): ReactNode {
|
|
2030
|
-
const { Banner, Text, Heading, Grid, GridCell, Tabs, StatusBadge, Metric } =
|
|
2221
|
+
const { Banner, Text, Heading, Grid, GridCell, Card, Tabs, StatusBadge, Metric } =
|
|
2222
|
+
usePrimitives();
|
|
2031
2223
|
const t = useTranslation();
|
|
2032
2224
|
const effectiveTranslate = translate ?? t;
|
|
2033
2225
|
const nav = useNav();
|
|
2034
2226
|
const idParam = screen.idParam ?? "id";
|
|
2035
2227
|
const isTabsMode = screen.layout.mode === "tabs";
|
|
2036
2228
|
const activeSection = useMemo(() => {
|
|
2037
|
-
if (!isTabsMode) return undefined;
|
|
2229
|
+
if (!isTabsMode || Tabs === undefined) return undefined;
|
|
2038
2230
|
const tabParam = nav.searchParams["tab"];
|
|
2039
2231
|
return (
|
|
2040
2232
|
screen.layout.sections.find((section) => section.id === tabParam) ?? screen.layout.sections[0]
|
|
2041
2233
|
);
|
|
2042
|
-
}, [isTabsMode, screen.layout.sections, nav.searchParams]);
|
|
2043
|
-
// Tabs is an optional Core-Primitive
|
|
2044
|
-
//
|
|
2045
|
-
// app hasn't upgraded its createKumikoApp wiring yet.
|
|
2234
|
+
}, [isTabsMode, Tabs, screen.layout.sections, nav.searchParams]);
|
|
2235
|
+
// Tabs is an optional Core-Primitive: without it the screen falls back to
|
|
2236
|
+
// the stacked all-sections layout instead of silently truncating to section 1.
|
|
2046
2237
|
useEffect(() => {
|
|
2047
2238
|
if (isTabsMode && Tabs === undefined) {
|
|
2048
2239
|
// biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
|
|
@@ -2100,6 +2291,7 @@ function ProjectionDetailBody({
|
|
|
2100
2291
|
return {
|
|
2101
2292
|
id: "edit",
|
|
2102
2293
|
label: effectiveTranslate("kumiko.actions.edit"),
|
|
2294
|
+
icon: resolveActionIcon("edit"),
|
|
2103
2295
|
onPress: () =>
|
|
2104
2296
|
nav.navigate({ screenId: targetScreenId, ...(entityId !== undefined && { entityId }) }),
|
|
2105
2297
|
};
|
|
@@ -2132,10 +2324,12 @@ function ProjectionDetailBody({
|
|
|
2132
2324
|
// expliziten entityId für projectionDetail-entity-Targets.
|
|
2133
2325
|
const targetEntity = action.entity;
|
|
2134
2326
|
const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
|
|
2327
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
2135
2328
|
out.push({
|
|
2136
2329
|
id: action.id,
|
|
2137
2330
|
label: effectiveTranslate(action.label),
|
|
2138
2331
|
...(action.style !== undefined && { style: action.style }),
|
|
2332
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2139
2333
|
onPress: () => {
|
|
2140
2334
|
if (id === "") return;
|
|
2141
2335
|
nav.navigate({ entity: targetEntity, id });
|
|
@@ -2163,10 +2357,12 @@ function ProjectionDetailBody({
|
|
|
2163
2357
|
const fallback = targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
|
|
2164
2358
|
const navEntityId = explicit ?? fallback;
|
|
2165
2359
|
const targetScreen = action.screen;
|
|
2360
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
2166
2361
|
out.push({
|
|
2167
2362
|
id: action.id,
|
|
2168
2363
|
label: effectiveTranslate(action.label),
|
|
2169
2364
|
...(action.style !== undefined && { style: action.style }),
|
|
2365
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2170
2366
|
onPress: () => {
|
|
2171
2367
|
nav.navigate({
|
|
2172
2368
|
screenId: targetScreen,
|
|
@@ -2186,6 +2382,7 @@ function ProjectionDetailBody({
|
|
|
2186
2382
|
id: writeAction.id,
|
|
2187
2383
|
label: effectiveTranslate(writeAction.label),
|
|
2188
2384
|
...(writeAction.style !== undefined && { style: writeAction.style }),
|
|
2385
|
+
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
2189
2386
|
...(writeAction.confirm !== undefined && {
|
|
2190
2387
|
confirm: effectiveTranslate(writeAction.confirm),
|
|
2191
2388
|
}),
|
|
@@ -2261,52 +2458,60 @@ function ProjectionDetailBody({
|
|
|
2261
2458
|
const header = screen.header;
|
|
2262
2459
|
const headerContent = (
|
|
2263
2460
|
<>
|
|
2264
|
-
{
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
{header.subtitle !== undefined && (
|
|
2272
|
-
<
|
|
2273
|
-
{
|
|
2274
|
-
|
|
2461
|
+
{(hasHeader || hasMetrics) && (
|
|
2462
|
+
<Card>
|
|
2463
|
+
{header !== undefined && (
|
|
2464
|
+
<>
|
|
2465
|
+
<Heading variant="page" testId="kumiko-screen-projection-detail-title">
|
|
2466
|
+
{String(record[header.title] ?? "")}
|
|
2467
|
+
</Heading>
|
|
2468
|
+
{(header.subtitle !== undefined || header.status !== undefined) && (
|
|
2469
|
+
<Grid columns="auto">
|
|
2470
|
+
{header.subtitle !== undefined && (
|
|
2471
|
+
<Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
|
|
2472
|
+
{String(record[header.subtitle] ?? "")}
|
|
2473
|
+
</Text>
|
|
2474
|
+
)}
|
|
2475
|
+
{header.status !== undefined &&
|
|
2476
|
+
(StatusBadge !== undefined ? (
|
|
2477
|
+
<StatusBadge
|
|
2478
|
+
value={String(record[header.status] ?? "")}
|
|
2479
|
+
tone={statusToneForValue(String(record[header.status] ?? ""))}
|
|
2480
|
+
testId="kumiko-screen-projection-detail-status"
|
|
2481
|
+
/>
|
|
2482
|
+
) : (
|
|
2483
|
+
<Text testId="kumiko-screen-projection-detail-status">
|
|
2484
|
+
{String(record[header.status] ?? "")}
|
|
2485
|
+
</Text>
|
|
2486
|
+
))}
|
|
2487
|
+
</Grid>
|
|
2275
2488
|
)}
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2489
|
+
</>
|
|
2490
|
+
)}
|
|
2491
|
+
{hasMetrics && (
|
|
2492
|
+
<Grid
|
|
2493
|
+
columns={screen.metrics?.length ?? 1}
|
|
2494
|
+
testId="kumiko-screen-projection-detail-metrics"
|
|
2495
|
+
>
|
|
2496
|
+
{screen.metrics?.map((metric) => {
|
|
2497
|
+
const labelKey = screen.fieldLabels?.[metric];
|
|
2498
|
+
const label = labelKey !== undefined ? effectiveTranslate(labelKey) : metric;
|
|
2499
|
+
const value = String(record[metric] ?? "");
|
|
2500
|
+
const testId = `kumiko-screen-projection-detail-metric-${metric}`;
|
|
2501
|
+
return Metric !== undefined ? (
|
|
2502
|
+
<Metric key={metric} label={label} value={value} testId={testId} />
|
|
2282
2503
|
) : (
|
|
2283
|
-
<
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2504
|
+
<GridCell key={metric}>
|
|
2505
|
+
<Text variant="small" testId={`${testId}-label`}>
|
|
2506
|
+
{label}
|
|
2507
|
+
</Text>
|
|
2508
|
+
<Text testId={`${testId}-value`}>{value}</Text>
|
|
2509
|
+
</GridCell>
|
|
2510
|
+
);
|
|
2511
|
+
})}
|
|
2287
2512
|
</Grid>
|
|
2288
2513
|
)}
|
|
2289
|
-
|
|
2290
|
-
)}
|
|
2291
|
-
{hasMetrics && (
|
|
2292
|
-
<Grid columns="auto" testId="kumiko-screen-projection-detail-metrics">
|
|
2293
|
-
{screen.metrics?.map((metric) => {
|
|
2294
|
-
const labelKey = screen.fieldLabels?.[metric];
|
|
2295
|
-
const label = labelKey !== undefined ? effectiveTranslate(labelKey) : metric;
|
|
2296
|
-
const value = String(record[metric] ?? "");
|
|
2297
|
-
const testId = `kumiko-screen-projection-detail-metric-${metric}`;
|
|
2298
|
-
return Metric !== undefined ? (
|
|
2299
|
-
<Metric key={metric} label={label} value={value} testId={testId} />
|
|
2300
|
-
) : (
|
|
2301
|
-
<GridCell key={metric}>
|
|
2302
|
-
<Text variant="small" testId={`${testId}-label`}>
|
|
2303
|
-
{label}
|
|
2304
|
-
</Text>
|
|
2305
|
-
<Text testId={`${testId}-value`}>{value}</Text>
|
|
2306
|
-
</GridCell>
|
|
2307
|
-
);
|
|
2308
|
-
})}
|
|
2309
|
-
</Grid>
|
|
2514
|
+
</Card>
|
|
2310
2515
|
)}
|
|
2311
2516
|
{hasTabs && activeSection !== undefined && (
|
|
2312
2517
|
<Tabs
|
|
@@ -2331,7 +2536,7 @@ function ProjectionDetailBody({
|
|
|
2331
2536
|
customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
|
|
2332
2537
|
{...(headerActions !== undefined && { actions: headerActions })}
|
|
2333
2538
|
{...(translate !== undefined && { translate })}
|
|
2334
|
-
{...(
|
|
2539
|
+
{...(hasTabs && { hideSectionTitles: true })}
|
|
2335
2540
|
{...((hasHeader || hasMetrics || hasTabs) && { headerRegion: headerContent })}
|
|
2336
2541
|
valueDisplay={screen.valueDisplay ?? "text"}
|
|
2337
2542
|
/>
|