@cosmicdrift/kumiko-renderer 0.202.0 → 0.204.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.
@@ -0,0 +1,212 @@
1
+ // fw#2165: ProjectionListBody used to fetch with a hardcoded empty payload —
2
+ // search/sort/pagination were rendered but had no effect. This renders the
3
+ // real path (KumikoScreen → ProjectionListScreen → ProjectionListBody →
4
+ // RenderList) under a stub dispatcher that records every query() call, and a
5
+ // stateful NavProvider so setSearchParams actually re-renders — proving the
6
+ // URL-state → payload wiring for the two capabilities buildAppSchema derives
7
+ // per-screen (searchable, sortable).
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
11
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
12
+ import { act, render, waitFor } from "@testing-library/react";
13
+ import { type ComponentType, type ReactNode, useState } from "react";
14
+ import { DispatcherProvider } from "../../context/dispatcher-context";
15
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
16
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
17
+ import { type CorePrimitives, type DataTableProps, PrimitivesProvider } from "../../primitives";
18
+ import type { FeatureSchema } from "../feature-schema";
19
+ import { KumikoScreen } from "../kumiko-screen";
20
+ import type { NavApi } from "../nav";
21
+ import { NavProvider } from "../nav";
22
+
23
+ let capturedProps: DataTableProps | undefined;
24
+ const captureDataTable: ComponentType<DataTableProps> = (props) => {
25
+ capturedProps = props;
26
+ return null;
27
+ };
28
+ // Indirection defeats TS narrowing `capturedProps` to `undefined` at read
29
+ // sites — the compiler can't see that `captureDataTable` (a React render
30
+ // callback) reassigns it between the reset and the read.
31
+ const getCapturedProps = (): DataTableProps | undefined => capturedProps;
32
+ const noop = (): ReactNode => null;
33
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
34
+
35
+ const testPrimitives: CorePrimitives = {
36
+ Button: noop,
37
+ Banner: passChildren,
38
+ Field: passChildren,
39
+ Input: noop,
40
+ DataTable: captureDataTable,
41
+ Form: passChildren,
42
+ Section: passChildren,
43
+ Card: passChildren,
44
+ Grid: passChildren,
45
+ GridCell: passChildren,
46
+ Text: passChildren,
47
+ Heading: noop,
48
+ Dialog: noop,
49
+ Modal: noop,
50
+ Lightbox: noop,
51
+ ConfigSourceBadge: noop,
52
+ ConfigCascadeView: noop,
53
+ Link: noop,
54
+ };
55
+
56
+ let queryCalls: Array<{ readonly type: string; readonly payload: unknown }> = [];
57
+
58
+ function stubDispatcher(): Dispatcher {
59
+ return {
60
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
61
+ query: (async (type: string, payload: unknown) => {
62
+ queryCalls.push({ type, payload });
63
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
64
+ }) as unknown as Dispatcher["query"],
65
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
66
+ statusStore: {
67
+ getState: () => "online",
68
+ subscribe: () => () => {},
69
+ } as unknown as Dispatcher["statusStore"],
70
+ async *stream() {},
71
+ pendingWrites: () => [],
72
+ pendingFiles: () => [],
73
+ };
74
+ }
75
+
76
+ function buildSchema(screen: ProjectionListScreenDefinition): FeatureSchema {
77
+ return {
78
+ featureName: "ledger",
79
+ entities: {},
80
+ screens: [screen],
81
+ } as FeatureSchema;
82
+ }
83
+
84
+ // Stateful nav so setSearchParams (called by useListUrlState) actually
85
+ // re-renders the tree — a plain mock object wouldn't trigger React.
86
+ function StatefulNav({
87
+ initialParams,
88
+ children,
89
+ }: {
90
+ readonly initialParams: Record<string, string>;
91
+ readonly children: ReactNode;
92
+ }): ReactNode {
93
+ const [params, setParams] = useState<Record<string, string>>(initialParams);
94
+ const value: NavApi = {
95
+ route: { screenId: "ledger:screen:schedule-list" },
96
+ navigate: () => {},
97
+ replace: () => {},
98
+ hrefFor: () => "",
99
+ searchParams: params,
100
+ setSearchParams: (updates) => {
101
+ setParams((prev) => {
102
+ const next = { ...prev };
103
+ for (const [k, v] of Object.entries(updates)) {
104
+ if (v === null) delete next[k];
105
+ else next[k] = v;
106
+ }
107
+ return next;
108
+ });
109
+ },
110
+ };
111
+ return <NavProvider value={value}>{children}</NavProvider>;
112
+ }
113
+
114
+ function renderProjectionList(
115
+ screen: ProjectionListScreenDefinition,
116
+ initialParams: Record<string, string> = {},
117
+ ): void {
118
+ render(
119
+ <LocaleProvider
120
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
121
+ fallbackBundles={[kumikoDefaultTranslations]}
122
+ >
123
+ <DispatcherProvider dispatcher={stubDispatcher()}>
124
+ <StatefulNav initialParams={initialParams}>
125
+ <PrimitivesProvider value={testPrimitives}>
126
+ <KumikoScreen schema={buildSchema(screen)} qn="ledger:screen:schedule-list" />
127
+ </PrimitivesProvider>
128
+ </StatefulNav>
129
+ </DispatcherProvider>
130
+ </LocaleProvider>,
131
+ );
132
+ }
133
+
134
+ describe("ProjectionListBody — search/sort capability wiring (fw#2165)", () => {
135
+ test("search-capable screen: a URL search term lands in the query payload", async () => {
136
+ queryCalls = [];
137
+ renderProjectionList(
138
+ {
139
+ id: "schedule-list",
140
+ type: "projectionList",
141
+ query: "ledger:query:schedule:list",
142
+ columns: ["description"],
143
+ searchable: true,
144
+ },
145
+ { "schedule-list.q": "acme" },
146
+ );
147
+
148
+ await waitFor(() => expect(queryCalls.length).toBeGreaterThan(0));
149
+ expect(queryCalls[0]?.payload).toMatchObject({ search: "acme" });
150
+ });
151
+
152
+ test("non-search-capable screen: the same URL search term is NOT sent (schema doesn't accept it)", async () => {
153
+ queryCalls = [];
154
+ renderProjectionList(
155
+ {
156
+ id: "schedule-list",
157
+ type: "projectionList",
158
+ query: "ledger:query:schedule:list",
159
+ columns: ["description"],
160
+ searchable: false,
161
+ },
162
+ { "schedule-list.q": "acme" },
163
+ );
164
+
165
+ await waitFor(() => expect(queryCalls.length).toBeGreaterThan(0));
166
+ expect(queryCalls[0]?.payload).not.toHaveProperty("search");
167
+ });
168
+
169
+ test("sort-capable screen: the column is rendered sortable, and a header click updates state and payload", async () => {
170
+ queryCalls = [];
171
+ capturedProps = undefined;
172
+ renderProjectionList({
173
+ id: "schedule-list",
174
+ type: "projectionList",
175
+ query: "ledger:query:schedule:list",
176
+ columns: ["description"],
177
+ sortable: true,
178
+ defaultSort: { field: "description", dir: "asc" },
179
+ });
180
+
181
+ await waitFor(() => expect(capturedProps).toBeDefined());
182
+ const sortableProps = getCapturedProps();
183
+ if (sortableProps === undefined) throw new Error("DataTable was not rendered");
184
+ // Column-click affordance is only wired when the query schema accepts
185
+ // sort — DefaultDataTable gates the header click on col.sortable.
186
+ expect(sortableProps.columns.find((c) => c.field === "description")?.sortable).toBe(true);
187
+
188
+ const countBeforeClick = queryCalls.length;
189
+ await act(async () => {
190
+ capturedProps?.onSortChange?.({ field: "description", dir: "desc" });
191
+ });
192
+
193
+ await waitFor(() => expect(queryCalls.length).toBeGreaterThan(countBeforeClick));
194
+ const lastPayload = queryCalls[queryCalls.length - 1]?.payload;
195
+ expect(lastPayload).toMatchObject({ sort: "description", sortDirection: "desc" });
196
+ });
197
+
198
+ test("non-sort-capable screen: the column is rendered NOT sortable", async () => {
199
+ capturedProps = undefined;
200
+ renderProjectionList({
201
+ id: "schedule-list",
202
+ type: "projectionList",
203
+ query: "ledger:query:schedule:list",
204
+ columns: ["description"],
205
+ });
206
+
207
+ await waitFor(() => expect(capturedProps).toBeDefined());
208
+ const notSortableProps = getCapturedProps();
209
+ if (notSortableProps === undefined) throw new Error("DataTable was not rendered");
210
+ expect(notSortableProps.columns.find((c) => c.field === "description")?.sortable).toBe(false);
211
+ });
212
+ });
@@ -27,15 +27,16 @@ import type {
27
27
  import { fieldLabelKey } from "@cosmicdrift/kumiko-headless";
28
28
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
29
29
  import { extractCreatedId } from "../components/reference-create-dialog";
30
- import { RenderEdit } from "../components/render-edit";
30
+ import { RenderEdit, type RenderEditAction } from "../components/render-edit";
31
31
  import { RenderList, type ToolbarActionButton } from "../components/render-list";
32
32
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
33
33
  import { useUserRoles } from "../context/user-roles-context";
34
- import { useListUrlState } from "../hooks/use-list-url-state";
34
+ import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
35
35
  import { useQuery } from "../hooks/use-query";
36
36
  import { useTranslation } from "../i18n";
37
37
  import { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../primitives";
38
38
  import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
39
+ import { useAppFeatures } from "./app-features-context";
39
40
  import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
40
41
  import { useCustomScreenComponent } from "./custom-screens";
41
42
  import { useDashboardBody } from "./dashboard-body";
@@ -823,6 +824,40 @@ type PagedRows = {
823
824
  readonly total?: number;
824
825
  };
825
826
 
827
+ // Payload for the server-side list query handler (LIST_PAYLOAD_SCHEMA):
828
+ // search/sort/sortDirection/limit + offset/totalCount for pager mode OR
829
+ // cursor for infinite scroll. Shared by EntityListBody and
830
+ // ProjectionListBody so the two branches can't drift on this shape
831
+ // (fw#2165) — entity-only additions (screen.filter, faceted filters) are
832
+ // layered on top by the caller instead of living in here.
833
+ export function buildListQueryPayload(state: {
834
+ readonly limit: number;
835
+ readonly search: string;
836
+ readonly sort: ListSort | null;
837
+ readonly usePager: boolean;
838
+ readonly page: number;
839
+ readonly useInfinite: boolean;
840
+ readonly cursor: string | undefined;
841
+ }): Record<string, unknown> {
842
+ const payload: Record<string, unknown> = { limit: state.limit };
843
+ if (state.search !== "") payload["search"] = state.search;
844
+ if (state.sort !== null) {
845
+ payload["sort"] = state.sort.field;
846
+ payload["sortDirection"] = state.sort.dir;
847
+ }
848
+ if (state.usePager) {
849
+ // page=1 → offset=0, page=2 → offset=limit, etc. Server clamps itself
850
+ // when offset >= total.
851
+ const offset = (state.page - 1) * state.limit;
852
+ if (offset > 0) payload["offset"] = offset;
853
+ // totalCount: extra COUNT(*) so the pager can render "Page X of Y".
854
+ payload["totalCount"] = true;
855
+ } else if (state.useInfinite && state.cursor !== undefined) {
856
+ payload["cursor"] = state.cursor;
857
+ }
858
+ return payload;
859
+ }
860
+
826
861
  function EntityListScreen({
827
862
  schema,
828
863
  screen,
@@ -935,16 +970,18 @@ function EntityListBody({
935
970
  return out;
936
971
  }, [urlState.filters, entity.fields]);
937
972
 
938
- // Payload für den Server-Query-Handler (LIST_PAYLOAD_SCHEMA):
939
- // search/sort/sortDirection/limit + offset/totalCount für Pager-Mode
940
- // ODER cursor für Infinite-Scroll.
973
+ // Entity-only additions (screen.filter, faceted filters) layer on top of
974
+ // the shared buildListQueryPayload projectionList has neither.
941
975
  const queryPayload = useMemo(() => {
942
- const payload: Record<string, unknown> = { limit };
943
- if (urlState.q !== "") payload["search"] = urlState.q;
944
- if (effectiveSort !== null) {
945
- payload["sort"] = effectiveSort.field;
946
- payload["sortDirection"] = effectiveSort.dir;
947
- }
976
+ const payload = buildListQueryPayload({
977
+ limit,
978
+ search: urlState.q,
979
+ sort: effectiveSort,
980
+ usePager,
981
+ page: urlState.page,
982
+ useInfinite,
983
+ cursor,
984
+ });
948
985
  // Screen-Filter (Tier 2.7c) — vom Author am Schema deklariert,
949
986
  // unabhängig vom User-q-Search. Mehrere Buckets derselben Entity
950
987
  // ("Upcoming" / "Active" / "Past") nutzen unterschiedliche filter
@@ -955,18 +992,6 @@ function EntityListBody({
955
992
  if (filterPayload.length > 0) {
956
993
  payload["filters"] = filterPayload;
957
994
  }
958
- if (usePager) {
959
- // page=1 → offset=0, page=2 → offset=limit, etc. Server
960
- // clampt selbst wenn offset >= total.
961
- const offset = (urlState.page - 1) * limit;
962
- if (offset > 0) payload["offset"] = offset;
963
- // totalCount: extra COUNT(*) damit der Pager "Page X of Y"
964
- // rendern kann. Bei pagination=false oder "infinite" sparen wir
965
- // den Roundtrip.
966
- payload["totalCount"] = true;
967
- } else if (useInfinite && cursor !== undefined) {
968
- payload["cursor"] = cursor;
969
- }
970
995
  return payload;
971
996
  }, [
972
997
  limit,
@@ -1337,11 +1362,13 @@ function EntityListBody({
1337
1362
 
1338
1363
  // ---- projection-list ----
1339
1364
 
1340
- // Wie entityList, aber die List-Query kommt DIREKT aus `screen.query` (statt aus
1341
- // der Entity abgeleitet) — dadurch cross-feature-fähig. v1 bewusst schlank:
1342
- // rendert die Query-Rows mit den (explizit gelabelten) Columns + navigate-
1343
- // RowActions/Row-Klick. Kein Server-Sort/-Pagination/-Facetten (eine Projection-
1344
- // Query hat dafür keinen garantierten Contract) die kommen bei Bedarf später.
1365
+ // Like entityList, but the list query comes DIRECTLY from `screen.query`
1366
+ // (instead of being derived from an entity) — cross-feature capable.
1367
+ // search/sort/pagination reuse the same URL-state + buildListQueryPayload
1368
+ // wiring as EntityListBody; which of them are actually offered is derived
1369
+ // from the query handler's Zod schema at buildAppSchema time (fw#2165), not
1370
+ // declared here. Pages-mode pagination only — infinite-scroll accumulation
1371
+ // isn't wired for projectionList.
1345
1372
  function ProjectionListBody({
1346
1373
  schema,
1347
1374
  screen,
@@ -1358,9 +1385,44 @@ function ProjectionListBody({
1358
1385
  const nav = useNav();
1359
1386
  const dispatcher = useOptionalDispatcher();
1360
1387
  const effectiveTranslate = translate ?? t;
1361
- const entity = useMemo(() => synthesizeProjectionEntity(screen.columns), [screen.columns]);
1388
+
1389
+ // searchable/sortable/paginated are derived at buildAppSchema time from the
1390
+ // query handler's Zod schema (fw#2165) — not authored on the screen.
1391
+ const searchable = screen.searchable ?? false;
1392
+ const sortable = screen.sortable ?? false;
1393
+ const paginated = screen.paginated ?? false;
1394
+ const entity = useMemo(
1395
+ () => synthesizeProjectionEntity(screen.columns, sortable),
1396
+ [screen.columns, sortable],
1397
+ );
1362
1398
  const listScreen = useMemo(() => synthesizeProjectionScreen(screen), [screen]);
1363
- const rowsQuery = useQuery<PagedRows>(screen.query, {}, { live: true });
1399
+
1400
+ const urlState = useListUrlState(screen.id);
1401
+ // Gated on the derived capability, not just on whether URL-state happens to
1402
+ // carry a value — a stale/hand-crafted URL (?…q=x on a non-searchable
1403
+ // screen) must not smuggle a param the query's Zod schema doesn't accept.
1404
+ const activeSearch = searchable ? urlState.q : "";
1405
+ const activeSort = sortable ? (urlState.sort ?? screen.defaultSort ?? null) : null;
1406
+ const limit = screen.pageSize ?? 50;
1407
+ // Pages-mode only (see header comment) — an author-set pagination:
1408
+ // "infinite" on a paginated projectionList is silently a no-op today.
1409
+ const usePager = paginated && (screen.pagination ?? "pages") === "pages";
1410
+
1411
+ const queryPayload = useMemo(
1412
+ () =>
1413
+ buildListQueryPayload({
1414
+ limit,
1415
+ search: activeSearch,
1416
+ sort: activeSort,
1417
+ usePager,
1418
+ page: urlState.page,
1419
+ useInfinite: false,
1420
+ cursor: undefined,
1421
+ }),
1422
+ [limit, activeSearch, activeSort, usePager, urlState.page],
1423
+ );
1424
+
1425
+ const rowsQuery = useQuery<PagedRows>(screen.query, queryPayload, { live: true });
1364
1426
 
1365
1427
  const runNavigate = useCallback(
1366
1428
  (action: RowActionNavigate, row: ListRowViewModel) => {
@@ -1507,14 +1569,27 @@ function ProjectionListBody({
1507
1569
  ? (row: ListRowViewModel) => onRowClick(row, listScreen.entity)
1508
1570
  : undefined;
1509
1571
 
1572
+ // Same pager-construction as EntityListBody: no Pager UI until the
1573
+ // server-provided `total` arrives (guards pagination="pages" without
1574
+ // totalCount support, see fw#2165 report on solon's leaseOverviewHandler).
1575
+ const total = rowsQuery.data?.total;
1576
+ const pager =
1577
+ usePager && total !== undefined
1578
+ ? { page: urlState.page, limit, total, onPageChange: urlState.setPage }
1579
+ : undefined;
1580
+
1510
1581
  return (
1511
1582
  <RenderList
1512
1583
  screen={listScreen}
1513
1584
  entity={entity}
1514
1585
  rows={rowsQuery.data?.rows ?? []}
1515
1586
  featureName={schema.featureName}
1516
- searchable={screen.searchable ?? false}
1517
- sort={screen.defaultSort ?? null}
1587
+ searchable={searchable}
1588
+ searchValue={urlState.q}
1589
+ onSearchChange={urlState.setQ}
1590
+ sort={activeSort}
1591
+ onSortChange={urlState.setSort}
1592
+ {...(pager !== undefined && { pager })}
1518
1593
  {...(rowActions !== undefined && { rowActions })}
1519
1594
  {...(toolbarActions !== undefined && { toolbarActions })}
1520
1595
  {...(translate !== undefined && { translate })}
@@ -1540,7 +1615,10 @@ function ProjectionDetailBody({
1540
1615
  entityId,
1541
1616
  }: {
1542
1617
  readonly schema: FeatureSchema;
1543
- readonly screen: ProjectionDetailScreenDefinition;
1618
+ // detailFor sits on the ScreenDefinition intersection, not on the
1619
+ // projectionDetail variant itself (screen.ts:832) — widen the prop type
1620
+ // to keep reading it here instead of re-deriving it from schema.screens.
1621
+ readonly screen: ProjectionDetailScreenDefinition & { readonly detailFor?: string };
1544
1622
  readonly translate?: Translate;
1545
1623
  readonly entityId?: string;
1546
1624
  }): ReactNode {
@@ -1561,6 +1639,144 @@ function ProjectionDetailBody({
1561
1639
  nav.navigate({ screenId: screen.listScreenId });
1562
1640
  }, [nav, screen.listScreenId]);
1563
1641
 
1642
+ // Default edit action (fw#2166): resolved cross-feature over ALL mounted
1643
+ // features, not just this feature's own schema — detailFor itself is
1644
+ // resolved cross-feature by the boot-validator (detail-screens.ts), and
1645
+ // the motivating case is a projectionDetail whose query belongs to one
1646
+ // feature while the entity's entityEdit screen lives in another. Unlike
1647
+ // useNavigateToCreateFor this must NOT filter on allowCreate/singleton —
1648
+ // those gate create-targets, we're resolving an update-target by a
1649
+ // known id.
1650
+ const appFeatures = useAppFeatures();
1651
+ const userRoles = useUserRoles();
1652
+ const dispatcher = useOptionalDispatcher();
1653
+ const editScreen = useMemo(() => {
1654
+ const detailFor = screen.detailFor;
1655
+ if (detailFor === undefined) return undefined;
1656
+ for (const feature of appFeatures) {
1657
+ // Access-check is part of the find predicate, not a filter applied
1658
+ // after the first match — two entityEdit screens for the same entity
1659
+ // where the first is role-gated must not hide an accessible second one.
1660
+ const match = feature.screens.find(
1661
+ (s): s is EntityEditScreenDefinition =>
1662
+ s.type === "entityEdit" &&
1663
+ s.entity === detailFor &&
1664
+ screenAccessAllows(s.access, userRoles),
1665
+ );
1666
+ if (match !== undefined) return match;
1667
+ }
1668
+ return undefined;
1669
+ }, [appFeatures, screen.detailFor, userRoles]);
1670
+ const defaultEditAction = useMemo((): RenderEditAction | undefined => {
1671
+ if (editScreen === undefined) return undefined;
1672
+ // editScreen.id is registry-qualified ("feature:screen:contact-edit");
1673
+ // nav.navigate expects the short form (see useNavigateToCreateFor above).
1674
+ const targetScreenId = lastSegment(editScreen.id);
1675
+ return {
1676
+ id: "edit",
1677
+ label: effectiveTranslate("kumiko.actions.edit"),
1678
+ onPress: () =>
1679
+ nav.navigate({ screenId: targetScreenId, ...(entityId !== undefined && { entityId }) }),
1680
+ };
1681
+ }, [editScreen, effectiveTranslate, nav, entityId]);
1682
+
1683
+ const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
1684
+ const record = detailQuery.data ?? {};
1685
+ const declaredHasEdit = screen.actions?.some((a) => a.id === "edit") === true;
1686
+ const out: RenderEditAction[] = [];
1687
+ if (defaultEditAction !== undefined && !declaredHasEdit) {
1688
+ out.push(defaultEditAction);
1689
+ }
1690
+ for (const action of screen.actions ?? []) {
1691
+ if (action.visible !== undefined && !evalFieldCondition(action.visible, record)) {
1692
+ continue;
1693
+ }
1694
+ if (action.kind === "navigate") {
1695
+ // Default entityId for an entityEdit target of the SAME entity
1696
+ // (screen.detailFor plays entity's role here, like screen.entity
1697
+ // does for entityList's runNavigate) — without this fallback the
1698
+ // target opens an empty create-form instead of the shown record,
1699
+ // silently. Searched cross-feature, consistent with editScreen above.
1700
+ const explicit =
1701
+ action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
1702
+ const targetIsEntityEditSameEntity =
1703
+ screen.detailFor !== undefined &&
1704
+ appFeatures.some((feature) =>
1705
+ feature.screens.some(
1706
+ (s) =>
1707
+ s.type === "entityEdit" &&
1708
+ s.entity === screen.detailFor &&
1709
+ lastSegment(s.id) === action.screen,
1710
+ ),
1711
+ );
1712
+ const fallback = targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
1713
+ const navEntityId = explicit ?? fallback;
1714
+ const targetScreen = action.screen;
1715
+ out.push({
1716
+ id: action.id,
1717
+ label: effectiveTranslate(action.label),
1718
+ ...(action.style !== undefined && { style: action.style }),
1719
+ onPress: () => {
1720
+ nav.navigate({
1721
+ screenId: targetScreen,
1722
+ ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
1723
+ });
1724
+ const params =
1725
+ action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
1726
+ if (params !== undefined) {
1727
+ const stringified: Record<string, string | null> = {};
1728
+ for (const [k, v] of Object.entries(params)) {
1729
+ stringified[k] = v === null || v === undefined ? null : String(v);
1730
+ }
1731
+ nav.setSearchParams(stringified);
1732
+ }
1733
+ },
1734
+ });
1735
+ continue;
1736
+ }
1737
+ // writeHandler — same dispatch/refetch/failure-surfacing pattern as
1738
+ // ProjectionListBody's rowActions/toolbarActions above.
1739
+ if (dispatcher === undefined) continue;
1740
+ const writeAction = action;
1741
+ out.push({
1742
+ id: writeAction.id,
1743
+ label: effectiveTranslate(writeAction.label),
1744
+ ...(writeAction.style !== undefined && { style: writeAction.style }),
1745
+ ...(writeAction.confirm !== undefined && {
1746
+ confirm: effectiveTranslate(writeAction.confirm),
1747
+ }),
1748
+ ...(writeAction.confirmLabel !== undefined && {
1749
+ confirmLabel: effectiveTranslate(writeAction.confirmLabel),
1750
+ }),
1751
+ onPress: async () => {
1752
+ const payload =
1753
+ writeAction.payload !== undefined
1754
+ ? evalRowExtractor(writeAction.payload, record)
1755
+ : { id: record["id"] };
1756
+ const result = await dispatcher.write(writeAction.handler, payload);
1757
+ if (!result.isSuccess) {
1758
+ throw new WriteFailedError(
1759
+ result.error,
1760
+ dispatcherErrorText(result.error, effectiveTranslate),
1761
+ );
1762
+ }
1763
+ await detailQuery.refetch();
1764
+ },
1765
+ });
1766
+ }
1767
+ return out.length > 0 ? out : undefined;
1768
+ }, [
1769
+ screen.actions,
1770
+ screen.detailFor,
1771
+ appFeatures,
1772
+ defaultEditAction,
1773
+ effectiveTranslate,
1774
+ nav,
1775
+ dispatcher,
1776
+ detailQuery.data,
1777
+ detailQuery.refetch,
1778
+ ]);
1779
+
1564
1780
  if (entityId === undefined) {
1565
1781
  return (
1566
1782
  <Banner padded variant="error" testId="kumiko-screen-projection-detail-missing-id">
@@ -1600,6 +1816,7 @@ function ProjectionDetailBody({
1600
1816
  entityId={entityId}
1601
1817
  customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
1602
1818
  onCancel={screen.listScreenId !== undefined ? navigateToList : undefined}
1819
+ {...(headerActions !== undefined && { actions: headerActions })}
1603
1820
  {...(translate !== undefined && { translate })}
1604
1821
  />
1605
1822
  );
@@ -2,19 +2,19 @@ import type {
2
2
  EditFieldSpec,
3
3
  EntityEditScreenDefinition,
4
4
  } from "@cosmicdrift/kumiko-framework/ui-types";
5
- import { isExtensionEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { isFieldsEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
6
6
 
7
7
  // Normalized field specs actually rendered by the screen's layout, extension
8
- // sections skipped. Both this and `layoutFieldNames` key off "rendered by
9
- // the layout" for the same reason: a field the user never sees gets no
10
- // chance to review/correct a value nor to fix a presence error
8
+ // (and relatedList) sections skipped. Both this and `layoutFieldNames` key
9
+ // off "rendered by the layout" for the same reason: a field the user never
10
+ // sees gets no chance to review/correct a value nor to fix a presence error
11
11
  // (search-param merge, #1708; presence schema in form-schema.ts).
12
12
  export function layoutEditFields(
13
13
  screen: EntityEditScreenDefinition,
14
14
  ): readonly Exclude<EditFieldSpec, string>[] {
15
15
  const specs: Exclude<EditFieldSpec, string>[] = [];
16
16
  for (const section of screen.layout.sections) {
17
- if (isExtensionEditSection(section)) continue;
17
+ if (!isFieldsEditSection(section)) continue;
18
18
  for (const spec of section.fields) {
19
19
  specs.push(normalizeEditField(spec));
20
20
  }
@@ -27,7 +27,7 @@ import type {
27
27
  ProjectionDetailScreenDefinition,
28
28
  } from "@cosmicdrift/kumiko-framework/ui-types";
29
29
  import {
30
- isExtensionEditSection,
30
+ isFieldsEditSection,
31
31
  normalizeEditField,
32
32
  PROJECTION_DETAIL_ENTITY as PROJECTION_DETAIL_PSEUDO_ENTITY,
33
33
  } from "@cosmicdrift/kumiko-framework/ui-types";
@@ -38,7 +38,8 @@ import {
38
38
  export function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefinition {
39
39
  const fields: Record<string, { type: "text" }> = {};
40
40
  for (const section of layout.sections) {
41
- if (isExtensionEditSection(section)) continue; // rejected at boot, defensive here
41
+ // relatedList and extension sections carry no `fields` to synthesize.
42
+ if (!isFieldsEditSection(section)) continue;
42
43
  for (const spec of section.fields) {
43
44
  fields[normalizeEditField(spec).field] = { type: "text" };
44
45
  }
@@ -52,7 +53,8 @@ export function synthesizeProjectionDetailScreen(
52
53
  screen: ProjectionDetailScreenDefinition,
53
54
  ): EntityEditScreenDefinition {
54
55
  const sections = screen.layout.sections.map((section) => {
55
- if (isExtensionEditSection(section)) return section; // rejected at boot, defensive here
56
+ // relatedList and extension sections pass through unchanged.
57
+ if (!isFieldsEditSection(section)) return section;
56
58
  return {
57
59
  ...section,
58
60
  fields: section.fields.map((spec) => ({ ...normalizeEditField(spec), readOnly: true })),
@@ -24,14 +24,19 @@ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
24
24
 
25
25
  const PROJECTION_PSEUDO_ENTITY = "__projection__";
26
26
 
27
- /** Minimale EntityDefinition aus den Column-Feldern: jedes Feld ein Text-Feld,
28
- * nicht sortierbar (eine Projection-Query hat keinen garantierten Server-Sort).
29
- * computeListViewModel liest nur `fields[<col>].type` → Text reicht; die
30
- * Präsentation kommt aus dem Column-Renderer + explizitem Label. */
31
- export function synthesizeProjectionEntity(columns: readonly ListColumnSpec[]): EntityDefinition {
32
- const fields: Record<string, { type: "text"; sortable: false }> = {};
27
+ /** Minimal EntityDefinition from the column list: every field is a text
28
+ * field. `sortable` applies uniformly to all columns — buildAppSchema
29
+ * derives it from the query's Zod schema (`screen.sortable`, fw#2165); the
30
+ * query itself has no per-field server-sort guarantee.
31
+ * computeListViewModel only reads `fields[<col>].type` text is enough,
32
+ * presentation comes from the column renderer + explicit label. */
33
+ export function synthesizeProjectionEntity(
34
+ columns: readonly ListColumnSpec[],
35
+ sortable: boolean,
36
+ ): EntityDefinition {
37
+ const fields: Record<string, { type: "text"; sortable: boolean }> = {};
33
38
  for (const col of columns) {
34
- fields[normalizeListColumn(col).field] = { type: "text", sortable: false };
39
+ fields[normalizeListColumn(col).field] = { type: "text", sortable };
35
40
  }
36
41
  return { fields } as unknown as EntityDefinition;
37
42
  }