@cosmicdrift/kumiko-renderer 0.233.0 → 0.234.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__/entity-list-row-action-icon.test.tsx +169 -0
- package/src/app/kumiko-screen.tsx +247 -44
- 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/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";
|
|
@@ -69,6 +77,63 @@ function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHan
|
|
|
69
77
|
return action.kind === "writeHandler" || action.kind === undefined;
|
|
70
78
|
}
|
|
71
79
|
|
|
80
|
+
// Part B (fw-ui-defaults): id-derived default icon for actions that never
|
|
81
|
+
// declared one — a screen author still gets a recognizable glyph instead of
|
|
82
|
+
// a bare label. Checked against the actually registered IconKey vocabulary
|
|
83
|
+
// (nav-icon.ts) — no entry for verbs without a matching icon (e.g. "start",
|
|
84
|
+
// "pause").
|
|
85
|
+
const ACTION_ICON_BY_ID: Readonly<Partial<Record<string, IconKey>>> = {
|
|
86
|
+
delete: "trash",
|
|
87
|
+
edit: "pencil",
|
|
88
|
+
create: "plus",
|
|
89
|
+
new: "plus",
|
|
90
|
+
add: "plus",
|
|
91
|
+
view: "eye",
|
|
92
|
+
open: "eye",
|
|
93
|
+
cancel: "x",
|
|
94
|
+
reject: "x",
|
|
95
|
+
complete: "check",
|
|
96
|
+
resolve: "check",
|
|
97
|
+
approve: "check",
|
|
98
|
+
archive: "archive",
|
|
99
|
+
publish: "upload",
|
|
100
|
+
duplicate: "copy",
|
|
101
|
+
copy: "copy",
|
|
102
|
+
download: "download",
|
|
103
|
+
refresh: "refresh",
|
|
104
|
+
retry: "refresh",
|
|
105
|
+
settings: "settings",
|
|
106
|
+
share: "share",
|
|
107
|
+
send: "send",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Ids are kebab-case (RowAction.id doc) — a compound id whose full form has
|
|
111
|
+
// no entry falls back to its last segment ("order-ship" -> "ship").
|
|
112
|
+
function kebabLastSegment(id: string): string {
|
|
113
|
+
const idx = id.lastIndexOf("-");
|
|
114
|
+
return idx === -1 ? id : id.slice(idx + 1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Resolution order: author-declared `icon` wins, then the id-derived
|
|
118
|
+
// default (full id, then its last kebab segment). `declared` is `undefined`
|
|
119
|
+
// for ToolbarAction, which has no author-facing icon field.
|
|
120
|
+
function resolveActionIcon(id: string, declared?: IconKey): IconKey | undefined {
|
|
121
|
+
if (declared !== undefined) return declared;
|
|
122
|
+
return ACTION_ICON_BY_ID[id] ?? ACTION_ICON_BY_ID[kebabLastSegment(id)];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Row-action column mode for a resolved action set: a group where every
|
|
126
|
+
// member carries an icon renders inline so `shouldRenderActionsIconOnly`
|
|
127
|
+
// can collapse it to icon-only buttons (fw#2580). Anything else keeps the
|
|
128
|
+
// DataTable's adaptive default (kebab past two actions) — inline text
|
|
129
|
+
// buttons for an icon-less group are the very thing the collapse avoids.
|
|
130
|
+
function rowActionModeFor(
|
|
131
|
+
actions: readonly DataTableRowAction[] | undefined,
|
|
132
|
+
): DataTableRowActionMode | undefined {
|
|
133
|
+
if (actions === undefined || !shouldRenderActionsIconOnly(actions)) return undefined;
|
|
134
|
+
return "inline";
|
|
135
|
+
}
|
|
136
|
+
|
|
72
137
|
// KumikoScreen picks up a ScreenDefinition from the schema by qn and
|
|
73
138
|
// routes it to the right renderer based on `screen.type`. Command
|
|
74
139
|
// qualification (`<feature>:write:<entity>:create` etc.) happens here
|
|
@@ -544,6 +609,10 @@ function EntityEditCreateBody({
|
|
|
544
609
|
},
|
|
545
610
|
[nav, screen.redirect, navigateToList, onSaved],
|
|
546
611
|
);
|
|
612
|
+
// Deliberately no `actions` prop here: `screen.actions` targets an
|
|
613
|
+
// EXISTING record (publish/archive/duplicate and friends), which the
|
|
614
|
+
// create branch has none of yet — see EntityEditUpdateForm for the
|
|
615
|
+
// wired-up counterpart.
|
|
547
616
|
return (
|
|
548
617
|
<RenderEdit
|
|
549
618
|
screen={screen}
|
|
@@ -712,7 +781,99 @@ function EntityEditUpdateForm({
|
|
|
712
781
|
|
|
713
782
|
const nav = useNav();
|
|
714
783
|
const dispatcher = useDispatcher();
|
|
784
|
+
const t = useTranslation();
|
|
785
|
+
const effectiveTranslate = translate ?? t;
|
|
715
786
|
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
787
|
+
// Header action buttons (fw entityEdit-actions) — same shape/dispatch
|
|
788
|
+
// pattern as ProjectionDetailBody.headerActions above (the edited record
|
|
789
|
+
// stands in for the "row"), minus the cross-feature defaultEditAction
|
|
790
|
+
// lookup that doesn't apply here (this screen already IS the edit form).
|
|
791
|
+
const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
|
|
792
|
+
const out: RenderEditAction[] = [];
|
|
793
|
+
for (const action of screen.actions ?? []) {
|
|
794
|
+
if (action.visible !== undefined && !evalFieldCondition(action.visible, record)) {
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (action.kind === "navigate") {
|
|
798
|
+
const runParams = (): void => {
|
|
799
|
+
const params =
|
|
800
|
+
action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
|
|
801
|
+
if (params !== undefined) {
|
|
802
|
+
nav.setSearchParams(stringifyNavParams(params));
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
806
|
+
if (action.entity !== undefined) {
|
|
807
|
+
const targetEntity = action.entity;
|
|
808
|
+
const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
|
|
809
|
+
out.push({
|
|
810
|
+
id: action.id,
|
|
811
|
+
label: effectiveTranslate(action.label),
|
|
812
|
+
...(action.style !== undefined && { style: action.style }),
|
|
813
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
814
|
+
onPress: () => {
|
|
815
|
+
if (id === "") return;
|
|
816
|
+
nav.navigate({ entity: targetEntity, id });
|
|
817
|
+
runParams();
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
} else if (action.screen !== undefined) {
|
|
821
|
+
// Default entityId for a screen-target: the currently edited
|
|
822
|
+
// record's own id — an entityEdit header action has no other
|
|
823
|
+
// "row" to derive one from (unlike ProjectionDetailBody, which
|
|
824
|
+
// needs the same-entity detailFor lookup instead).
|
|
825
|
+
const explicit =
|
|
826
|
+
action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
|
|
827
|
+
const navEntityId = explicit ?? entityId;
|
|
828
|
+
const targetScreen = action.screen;
|
|
829
|
+
out.push({
|
|
830
|
+
id: action.id,
|
|
831
|
+
label: effectiveTranslate(action.label),
|
|
832
|
+
...(action.style !== undefined && { style: action.style }),
|
|
833
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
834
|
+
onPress: () => {
|
|
835
|
+
nav.navigate({
|
|
836
|
+
screenId: targetScreen,
|
|
837
|
+
...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
|
|
838
|
+
});
|
|
839
|
+
runParams();
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
// writeHandler — same dispatch/reload/failure-surfacing pattern as
|
|
846
|
+
// ProjectionDetailBody's headerActions above.
|
|
847
|
+
const writeAction = action;
|
|
848
|
+
out.push({
|
|
849
|
+
id: writeAction.id,
|
|
850
|
+
label: effectiveTranslate(writeAction.label),
|
|
851
|
+
...(writeAction.style !== undefined && { style: writeAction.style }),
|
|
852
|
+
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
853
|
+
...(writeAction.confirm !== undefined && {
|
|
854
|
+
confirm: effectiveTranslate(writeAction.confirm),
|
|
855
|
+
}),
|
|
856
|
+
...(writeAction.confirmLabel !== undefined && {
|
|
857
|
+
confirmLabel: effectiveTranslate(writeAction.confirmLabel),
|
|
858
|
+
}),
|
|
859
|
+
onPress: async () => {
|
|
860
|
+
const payload =
|
|
861
|
+
writeAction.payload !== undefined
|
|
862
|
+
? evalRowExtractor(writeAction.payload, record)
|
|
863
|
+
: { id: entityId };
|
|
864
|
+
const result = await dispatcher.write(writeAction.handler, payload);
|
|
865
|
+
if (!result.isSuccess) {
|
|
866
|
+
throw new WriteFailedError(
|
|
867
|
+
result.error,
|
|
868
|
+
dispatcherErrorText(result.error, effectiveTranslate),
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
await onReload();
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
return out.length > 0 ? out : undefined;
|
|
876
|
+
}, [screen.actions, effectiveTranslate, nav, dispatcher, record, entityId, onReload]);
|
|
716
877
|
const handleSubmitted = useCallback(
|
|
717
878
|
(result: SubmitResult<unknown>) => {
|
|
718
879
|
if (!result.isSuccess) return;
|
|
@@ -760,6 +921,7 @@ function EntityEditUpdateForm({
|
|
|
760
921
|
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
761
922
|
{...(translate !== undefined && { translate })}
|
|
762
923
|
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
924
|
+
{...(headerActions !== undefined && { actions: headerActions })}
|
|
763
925
|
/>
|
|
764
926
|
);
|
|
765
927
|
}
|
|
@@ -1450,10 +1612,12 @@ function EntityListBody({
|
|
|
1450
1612
|
if (action.kind === "navigate") {
|
|
1451
1613
|
const navigateAction = action;
|
|
1452
1614
|
const actionVisible = action.visible;
|
|
1615
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
1453
1616
|
return {
|
|
1454
1617
|
id: action.id,
|
|
1455
1618
|
label: effectiveTranslate(action.label),
|
|
1456
1619
|
...(action.style !== undefined && { style: action.style }),
|
|
1620
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1457
1621
|
onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
|
|
1458
1622
|
...(actionVisible !== undefined && {
|
|
1459
1623
|
isVisible: (row: ListRowViewModel) => evalFieldCondition(actionVisible, row.values),
|
|
@@ -1468,6 +1632,7 @@ function EntityListBody({
|
|
|
1468
1632
|
id: writeAction.id,
|
|
1469
1633
|
label: effectiveTranslate(writeAction.label),
|
|
1470
1634
|
style: writeAction.style,
|
|
1635
|
+
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
1471
1636
|
confirm:
|
|
1472
1637
|
writeAction.confirm !== undefined ? effectiveTranslate(writeAction.confirm) : undefined,
|
|
1473
1638
|
confirmLabel:
|
|
@@ -1501,6 +1666,12 @@ function EntityListBody({
|
|
|
1501
1666
|
.filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
|
|
1502
1667
|
}, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, refreshRowsAfterWrite]);
|
|
1503
1668
|
|
|
1669
|
+
// Row actions that all resolve an icon render inline and collapse to
|
|
1670
|
+
// icon-only (fw#2580) — the adaptive default would bury more than two of
|
|
1671
|
+
// them in a kebab menu. A group with an icon-less member stays adaptive so
|
|
1672
|
+
// it never degrades into wall-to-wall text buttons.
|
|
1673
|
+
const rowActionMode = rowActionModeFor(rowActions);
|
|
1674
|
+
|
|
1504
1675
|
// ToolbarActions: Schema → Resolved-Form (analog rowActions).
|
|
1505
1676
|
// navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
|
|
1506
1677
|
// → dispatcher.write(handler, payload?()). KumikoScreen kennt schon
|
|
@@ -1509,11 +1680,13 @@ function EntityListBody({
|
|
|
1509
1680
|
if (screen.toolbarActions === undefined) return undefined;
|
|
1510
1681
|
return screen.toolbarActions
|
|
1511
1682
|
.map((action: ToolbarAction): ToolbarActionButton | null => {
|
|
1683
|
+
const actionIcon = resolveActionIcon(action.id);
|
|
1512
1684
|
if (action.kind === "navigate") {
|
|
1513
1685
|
return {
|
|
1514
1686
|
id: action.id,
|
|
1515
1687
|
label: effectiveTranslate(action.label),
|
|
1516
1688
|
...(action.style !== undefined && { style: action.style }),
|
|
1689
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1517
1690
|
onTrigger: () => nav.navigate({ screenId: action.screen }),
|
|
1518
1691
|
};
|
|
1519
1692
|
}
|
|
@@ -1522,6 +1695,7 @@ function EntityListBody({
|
|
|
1522
1695
|
id: action.id,
|
|
1523
1696
|
label: effectiveTranslate(action.label),
|
|
1524
1697
|
...(action.style !== undefined && { style: action.style }),
|
|
1698
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1525
1699
|
onTrigger: () => openDrawer(action),
|
|
1526
1700
|
};
|
|
1527
1701
|
}
|
|
@@ -1533,6 +1707,7 @@ function EntityListBody({
|
|
|
1533
1707
|
id: action.id,
|
|
1534
1708
|
label: effectiveTranslate(action.label),
|
|
1535
1709
|
...(action.style !== undefined && { style: action.style }),
|
|
1710
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1536
1711
|
...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
|
|
1537
1712
|
...(action.confirmLabel !== undefined && {
|
|
1538
1713
|
confirmLabel: effectiveTranslate(action.confirmLabel),
|
|
@@ -1638,6 +1813,7 @@ function EntityListBody({
|
|
|
1638
1813
|
onSortChange={urlState.setSort}
|
|
1639
1814
|
{...(pager !== undefined && { pager })}
|
|
1640
1815
|
{...(rowActions !== undefined && { rowActions })}
|
|
1816
|
+
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
1641
1817
|
{...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
|
|
1642
1818
|
{...(useInfinite && {
|
|
1643
1819
|
onReachEnd: loadMore,
|
|
@@ -1813,10 +1989,12 @@ function ProjectionListBody({
|
|
|
1813
1989
|
if (action.kind === "navigate") {
|
|
1814
1990
|
const navigateAction = action;
|
|
1815
1991
|
const visible = action.visible;
|
|
1992
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
1816
1993
|
out.push({
|
|
1817
1994
|
id: action.id,
|
|
1818
1995
|
label: effectiveTranslate(action.label),
|
|
1819
1996
|
...(action.style !== undefined && { style: action.style }),
|
|
1997
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1820
1998
|
onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
|
|
1821
1999
|
...(visible !== undefined && {
|
|
1822
2000
|
isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
|
|
@@ -1834,6 +2012,7 @@ function ProjectionListBody({
|
|
|
1834
2012
|
id: writeAction.id,
|
|
1835
2013
|
label: effectiveTranslate(writeAction.label),
|
|
1836
2014
|
...(writeAction.style !== undefined && { style: writeAction.style }),
|
|
2015
|
+
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
1837
2016
|
...(writeAction.confirm !== undefined && {
|
|
1838
2017
|
confirm: effectiveTranslate(writeAction.confirm),
|
|
1839
2018
|
}),
|
|
@@ -1863,16 +2042,22 @@ function ProjectionListBody({
|
|
|
1863
2042
|
return out.length > 0 ? out : undefined;
|
|
1864
2043
|
}, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher, rowsQuery.refetch]);
|
|
1865
2044
|
|
|
2045
|
+
// Same icon-only collapse as entityList (fw#2580) — projectionList rows go
|
|
2046
|
+
// through the identical RenderList/DataTable path.
|
|
2047
|
+
const rowActionMode = rowActionModeFor(rowActions);
|
|
2048
|
+
|
|
1866
2049
|
const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
|
|
1867
2050
|
if (screen.toolbarActions === undefined) return undefined;
|
|
1868
2051
|
const out: ToolbarActionButton[] = [];
|
|
1869
2052
|
for (const action of screen.toolbarActions) {
|
|
2053
|
+
const actionIcon = resolveActionIcon(action.id);
|
|
1870
2054
|
if (action.kind === "navigate") {
|
|
1871
2055
|
const target = action.screen;
|
|
1872
2056
|
out.push({
|
|
1873
2057
|
id: action.id,
|
|
1874
2058
|
label: effectiveTranslate(action.label),
|
|
1875
2059
|
...(action.style !== undefined && { style: action.style }),
|
|
2060
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1876
2061
|
onTrigger: () => nav.navigate({ screenId: target }),
|
|
1877
2062
|
});
|
|
1878
2063
|
continue;
|
|
@@ -1882,6 +2067,7 @@ function ProjectionListBody({
|
|
|
1882
2067
|
id: action.id,
|
|
1883
2068
|
label: effectiveTranslate(action.label),
|
|
1884
2069
|
...(action.style !== undefined && { style: action.style }),
|
|
2070
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1885
2071
|
onTrigger: () => openDrawer(action),
|
|
1886
2072
|
});
|
|
1887
2073
|
continue;
|
|
@@ -1892,6 +2078,7 @@ function ProjectionListBody({
|
|
|
1892
2078
|
id: action.id,
|
|
1893
2079
|
label: effectiveTranslate(action.label),
|
|
1894
2080
|
...(action.style !== undefined && { style: action.style }),
|
|
2081
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1895
2082
|
...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
|
|
1896
2083
|
...(action.confirmLabel !== undefined && {
|
|
1897
2084
|
confirmLabel: effectiveTranslate(action.confirmLabel),
|
|
@@ -1977,6 +2164,7 @@ function ProjectionListBody({
|
|
|
1977
2164
|
onSortChange={urlState.setSort}
|
|
1978
2165
|
{...(pager !== undefined && { pager })}
|
|
1979
2166
|
{...(rowActions !== undefined && { rowActions })}
|
|
2167
|
+
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
1980
2168
|
{...(toolbarActions !== undefined && { toolbarActions })}
|
|
1981
2169
|
{...(translate !== undefined && { translate })}
|
|
1982
2170
|
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
@@ -2027,7 +2215,8 @@ function ProjectionDetailBody({
|
|
|
2027
2215
|
readonly translate?: Translate;
|
|
2028
2216
|
readonly entityId?: string;
|
|
2029
2217
|
}): ReactNode {
|
|
2030
|
-
const { Banner, Text, Heading, Grid, GridCell, Tabs, StatusBadge, Metric } =
|
|
2218
|
+
const { Banner, Text, Heading, Grid, GridCell, Card, Tabs, StatusBadge, Metric } =
|
|
2219
|
+
usePrimitives();
|
|
2031
2220
|
const t = useTranslation();
|
|
2032
2221
|
const effectiveTranslate = translate ?? t;
|
|
2033
2222
|
const nav = useNav();
|
|
@@ -2100,6 +2289,7 @@ function ProjectionDetailBody({
|
|
|
2100
2289
|
return {
|
|
2101
2290
|
id: "edit",
|
|
2102
2291
|
label: effectiveTranslate("kumiko.actions.edit"),
|
|
2292
|
+
icon: resolveActionIcon("edit"),
|
|
2103
2293
|
onPress: () =>
|
|
2104
2294
|
nav.navigate({ screenId: targetScreenId, ...(entityId !== undefined && { entityId }) }),
|
|
2105
2295
|
};
|
|
@@ -2132,10 +2322,12 @@ function ProjectionDetailBody({
|
|
|
2132
2322
|
// expliziten entityId für projectionDetail-entity-Targets.
|
|
2133
2323
|
const targetEntity = action.entity;
|
|
2134
2324
|
const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
|
|
2325
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
2135
2326
|
out.push({
|
|
2136
2327
|
id: action.id,
|
|
2137
2328
|
label: effectiveTranslate(action.label),
|
|
2138
2329
|
...(action.style !== undefined && { style: action.style }),
|
|
2330
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2139
2331
|
onPress: () => {
|
|
2140
2332
|
if (id === "") return;
|
|
2141
2333
|
nav.navigate({ entity: targetEntity, id });
|
|
@@ -2163,10 +2355,12 @@ function ProjectionDetailBody({
|
|
|
2163
2355
|
const fallback = targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
|
|
2164
2356
|
const navEntityId = explicit ?? fallback;
|
|
2165
2357
|
const targetScreen = action.screen;
|
|
2358
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
2166
2359
|
out.push({
|
|
2167
2360
|
id: action.id,
|
|
2168
2361
|
label: effectiveTranslate(action.label),
|
|
2169
2362
|
...(action.style !== undefined && { style: action.style }),
|
|
2363
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2170
2364
|
onPress: () => {
|
|
2171
2365
|
nav.navigate({
|
|
2172
2366
|
screenId: targetScreen,
|
|
@@ -2186,6 +2380,7 @@ function ProjectionDetailBody({
|
|
|
2186
2380
|
id: writeAction.id,
|
|
2187
2381
|
label: effectiveTranslate(writeAction.label),
|
|
2188
2382
|
...(writeAction.style !== undefined && { style: writeAction.style }),
|
|
2383
|
+
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
2189
2384
|
...(writeAction.confirm !== undefined && {
|
|
2190
2385
|
confirm: effectiveTranslate(writeAction.confirm),
|
|
2191
2386
|
}),
|
|
@@ -2261,52 +2456,60 @@ function ProjectionDetailBody({
|
|
|
2261
2456
|
const header = screen.header;
|
|
2262
2457
|
const headerContent = (
|
|
2263
2458
|
<>
|
|
2264
|
-
{
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
{header.subtitle !== undefined && (
|
|
2272
|
-
<
|
|
2273
|
-
{
|
|
2274
|
-
|
|
2459
|
+
{(hasHeader || hasMetrics) && (
|
|
2460
|
+
<Card>
|
|
2461
|
+
{header !== undefined && (
|
|
2462
|
+
<>
|
|
2463
|
+
<Heading variant="page" testId="kumiko-screen-projection-detail-title">
|
|
2464
|
+
{String(record[header.title] ?? "")}
|
|
2465
|
+
</Heading>
|
|
2466
|
+
{(header.subtitle !== undefined || header.status !== undefined) && (
|
|
2467
|
+
<Grid columns="auto">
|
|
2468
|
+
{header.subtitle !== undefined && (
|
|
2469
|
+
<Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
|
|
2470
|
+
{String(record[header.subtitle] ?? "")}
|
|
2471
|
+
</Text>
|
|
2472
|
+
)}
|
|
2473
|
+
{header.status !== undefined &&
|
|
2474
|
+
(StatusBadge !== undefined ? (
|
|
2475
|
+
<StatusBadge
|
|
2476
|
+
value={String(record[header.status] ?? "")}
|
|
2477
|
+
tone={statusToneForValue(String(record[header.status] ?? ""))}
|
|
2478
|
+
testId="kumiko-screen-projection-detail-status"
|
|
2479
|
+
/>
|
|
2480
|
+
) : (
|
|
2481
|
+
<Text testId="kumiko-screen-projection-detail-status">
|
|
2482
|
+
{String(record[header.status] ?? "")}
|
|
2483
|
+
</Text>
|
|
2484
|
+
))}
|
|
2485
|
+
</Grid>
|
|
2275
2486
|
)}
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2487
|
+
</>
|
|
2488
|
+
)}
|
|
2489
|
+
{hasMetrics && (
|
|
2490
|
+
<Grid
|
|
2491
|
+
columns={screen.metrics?.length ?? 1}
|
|
2492
|
+
testId="kumiko-screen-projection-detail-metrics"
|
|
2493
|
+
>
|
|
2494
|
+
{screen.metrics?.map((metric) => {
|
|
2495
|
+
const labelKey = screen.fieldLabels?.[metric];
|
|
2496
|
+
const label = labelKey !== undefined ? effectiveTranslate(labelKey) : metric;
|
|
2497
|
+
const value = String(record[metric] ?? "");
|
|
2498
|
+
const testId = `kumiko-screen-projection-detail-metric-${metric}`;
|
|
2499
|
+
return Metric !== undefined ? (
|
|
2500
|
+
<Metric key={metric} label={label} value={value} testId={testId} />
|
|
2282
2501
|
) : (
|
|
2283
|
-
<
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2502
|
+
<GridCell key={metric}>
|
|
2503
|
+
<Text variant="small" testId={`${testId}-label`}>
|
|
2504
|
+
{label}
|
|
2505
|
+
</Text>
|
|
2506
|
+
<Text testId={`${testId}-value`}>{value}</Text>
|
|
2507
|
+
</GridCell>
|
|
2508
|
+
);
|
|
2509
|
+
})}
|
|
2287
2510
|
</Grid>
|
|
2288
2511
|
)}
|
|
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>
|
|
2512
|
+
</Card>
|
|
2310
2513
|
)}
|
|
2311
2514
|
{hasTabs && activeSection !== undefined && (
|
|
2312
2515
|
<Tabs
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Fields without a declared icon derive one from the field name — mirrors
|
|
2
|
+
// ACTION_ICON_BY_ID/resolveActionIcon (kumiko-screen.tsx) for fields. Only
|
|
3
|
+
// kind:"text" (single-line) and kind:"number" structurally carry an icon
|
|
4
|
+
// prop, so that's what these tests assert against.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import { render } from "@testing-library/react";
|
|
9
|
+
import type { ComponentType, ReactNode } from "react";
|
|
10
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
11
|
+
import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
|
|
12
|
+
import { RenderField } from "../render-field";
|
|
13
|
+
|
|
14
|
+
let captured: InputProps | undefined;
|
|
15
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
16
|
+
captured = props;
|
|
17
|
+
return null;
|
|
18
|
+
};
|
|
19
|
+
const noop = (): ReactNode => null;
|
|
20
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
21
|
+
|
|
22
|
+
const testPrimitives: CorePrimitives = {
|
|
23
|
+
Button: noop,
|
|
24
|
+
Banner: noop,
|
|
25
|
+
Field: passChildren,
|
|
26
|
+
Input: captureInput,
|
|
27
|
+
DataTable: noop,
|
|
28
|
+
Form: noop,
|
|
29
|
+
Section: noop,
|
|
30
|
+
Card: noop,
|
|
31
|
+
Grid: noop,
|
|
32
|
+
GridCell: noop,
|
|
33
|
+
Text: noop,
|
|
34
|
+
Heading: noop,
|
|
35
|
+
Dialog: noop,
|
|
36
|
+
Modal: noop,
|
|
37
|
+
Lightbox: noop,
|
|
38
|
+
ConfigSourceBadge: noop,
|
|
39
|
+
ConfigCascadeView: noop,
|
|
40
|
+
Link: noop,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function textField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
|
|
44
|
+
return {
|
|
45
|
+
field: "name",
|
|
46
|
+
label: "Name",
|
|
47
|
+
type: "text",
|
|
48
|
+
value: "",
|
|
49
|
+
visible: true,
|
|
50
|
+
readOnly: false,
|
|
51
|
+
required: false,
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderField(field: EditFieldViewModel): void {
|
|
57
|
+
captured = undefined;
|
|
58
|
+
render(
|
|
59
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
|
|
60
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
61
|
+
<RenderField field={field} onChange={() => {}} />
|
|
62
|
+
</PrimitivesProvider>
|
|
63
|
+
</LocaleProvider>,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("RenderField — icon derivation from field name/type", () => {
|
|
68
|
+
test("field named 'email' with no declared icon renders the mail icon", () => {
|
|
69
|
+
renderField(textField({ field: "email" }));
|
|
70
|
+
expect(captured?.kind).toBe("text");
|
|
71
|
+
if (captured?.kind === "text") expect(captured.icon).toBe("mail");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a declared field.icon overrides the derivation", () => {
|
|
75
|
+
renderField(textField({ field: "email", icon: "lock" }));
|
|
76
|
+
expect(captured?.kind).toBe("text");
|
|
77
|
+
if (captured?.kind === "text") expect(captured.icon).toBe("lock");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a generic text field with no recognizable name gets no icon", () => {
|
|
81
|
+
renderField(textField({ field: "foo" }));
|
|
82
|
+
expect(captured?.kind).toBe("text");
|
|
83
|
+
if (captured?.kind === "text") expect(captured.icon).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a boolean field gets no icon", () => {
|
|
87
|
+
renderField({
|
|
88
|
+
field: "isActive",
|
|
89
|
+
label: "Active",
|
|
90
|
+
type: "boolean",
|
|
91
|
+
value: false,
|
|
92
|
+
visible: true,
|
|
93
|
+
readOnly: false,
|
|
94
|
+
required: false,
|
|
95
|
+
});
|
|
96
|
+
expect(captured?.kind).toBe("boolean");
|
|
97
|
+
if (captured?.kind === "boolean") expect("icon" in captured).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a multiline field named 'email' gets no icon (textarea has no icon slot)", () => {
|
|
101
|
+
renderField(textField({ field: "email", multiline: true }));
|
|
102
|
+
expect(captured?.kind).toBe("textarea");
|
|
103
|
+
if (captured?.kind === "textarea") expect("icon" in captured).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a number field with no recognizable name gets no icon", () => {
|
|
107
|
+
renderField({
|
|
108
|
+
field: "quantity",
|
|
109
|
+
label: "Quantity",
|
|
110
|
+
type: "number",
|
|
111
|
+
value: 1,
|
|
112
|
+
visible: true,
|
|
113
|
+
readOnly: false,
|
|
114
|
+
required: false,
|
|
115
|
+
});
|
|
116
|
+
expect(captured?.kind).toBe("number");
|
|
117
|
+
if (captured?.kind === "number") expect(captured.icon).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|