@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.
@@ -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 { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../primitives";
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 } = usePrimitives();
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
- {header !== undefined && (
2265
- <>
2266
- <Heading variant="page" testId="kumiko-screen-projection-detail-title">
2267
- {String(record[header.title] ?? "")}
2268
- </Heading>
2269
- {(header.subtitle !== undefined || header.status !== undefined) && (
2270
- <Grid columns="auto">
2271
- {header.subtitle !== undefined && (
2272
- <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2273
- {String(record[header.subtitle] ?? "")}
2274
- </Text>
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
- {header.status !== undefined &&
2277
- (StatusBadge !== undefined ? (
2278
- <StatusBadge
2279
- value={String(record[header.status] ?? "")}
2280
- testId="kumiko-screen-projection-detail-status"
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
- <Text testId="kumiko-screen-projection-detail-status">
2284
- {String(record[header.status] ?? "")}
2285
- </Text>
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
+ });