@cosmicdrift/kumiko-renderer 0.201.0 → 0.203.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 +3 -3
- package/src/app/__tests__/build-list-query-payload.test.ts +107 -0
- package/src/app/__tests__/nav.test.ts +92 -1
- package/src/app/__tests__/projection-detail-actions.test.tsx +449 -0
- package/src/app/__tests__/projection-list-search-sort.test.tsx +212 -0
- package/src/app/kumiko-screen.tsx +254 -45
- package/src/app/nav.tsx +43 -2
- package/src/app/projection-list-shim.ts +12 -7
- package/src/app/qualify-screen-id.ts +6 -0
- package/src/components/render-edit.tsx +102 -0
- package/src/index.ts +10 -2
|
@@ -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";
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
} from "./projection-detail-shim";
|
|
50
51
|
import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
|
|
51
52
|
import { lastSegment, toKebab } from "./qn";
|
|
53
|
+
import { qualifyScreenId } from "./qualify-screen-id";
|
|
52
54
|
import { screenAccessAllows } from "./screen-access";
|
|
53
55
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
54
56
|
|
|
@@ -100,19 +102,10 @@ export type KumikoScreenProps = {
|
|
|
100
102
|
readonly onCopyLink?: () => Promise<void> | void;
|
|
101
103
|
};
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
// <feature>:screen:<short-id>. Matches the rule in
|
|
105
|
-
// packages/framework/src/engine/qualified-name.ts so client lookups
|
|
106
|
-
// line up with server-side registry state.
|
|
107
|
-
export function qualifyScreenId(featureName: string, screenId: string): string {
|
|
108
|
-
return `${featureName}:screen:${screenId}`;
|
|
109
|
-
}
|
|
105
|
+
export { qualifyScreenId };
|
|
110
106
|
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
* baut (z.B. WorkspaceShell-Resolver) sollte das hier durchreichen statt
|
|
114
|
-
* String-Concat damit ein zukünftiger QN-Schema-Wechsel an einer Stelle
|
|
115
|
-
* greift. */
|
|
107
|
+
/** Mirrors qualifyScreenId for nav QNs (registry form `<feature>:nav:<short-id>`).
|
|
108
|
+
* Callers building QNs (e.g. the WorkspaceShell resolver) should use this instead of string-concat, so a QN-schema change only touches one place. */
|
|
116
109
|
export function qualifyNavId(featureName: string, navId: string): string {
|
|
117
110
|
return `${featureName}:nav:${navId}`;
|
|
118
111
|
}
|
|
@@ -831,6 +824,40 @@ type PagedRows = {
|
|
|
831
824
|
readonly total?: number;
|
|
832
825
|
};
|
|
833
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
|
+
|
|
834
861
|
function EntityListScreen({
|
|
835
862
|
schema,
|
|
836
863
|
screen,
|
|
@@ -943,16 +970,18 @@ function EntityListBody({
|
|
|
943
970
|
return out;
|
|
944
971
|
}, [urlState.filters, entity.fields]);
|
|
945
972
|
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
// 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.
|
|
949
975
|
const queryPayload = useMemo(() => {
|
|
950
|
-
const payload
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
976
|
+
const payload = buildListQueryPayload({
|
|
977
|
+
limit,
|
|
978
|
+
search: urlState.q,
|
|
979
|
+
sort: effectiveSort,
|
|
980
|
+
usePager,
|
|
981
|
+
page: urlState.page,
|
|
982
|
+
useInfinite,
|
|
983
|
+
cursor,
|
|
984
|
+
});
|
|
956
985
|
// Screen-Filter (Tier 2.7c) — vom Author am Schema deklariert,
|
|
957
986
|
// unabhängig vom User-q-Search. Mehrere Buckets derselben Entity
|
|
958
987
|
// ("Upcoming" / "Active" / "Past") nutzen unterschiedliche filter
|
|
@@ -963,18 +992,6 @@ function EntityListBody({
|
|
|
963
992
|
if (filterPayload.length > 0) {
|
|
964
993
|
payload["filters"] = filterPayload;
|
|
965
994
|
}
|
|
966
|
-
if (usePager) {
|
|
967
|
-
// page=1 → offset=0, page=2 → offset=limit, etc. Server
|
|
968
|
-
// clampt selbst wenn offset >= total.
|
|
969
|
-
const offset = (urlState.page - 1) * limit;
|
|
970
|
-
if (offset > 0) payload["offset"] = offset;
|
|
971
|
-
// totalCount: extra COUNT(*) damit der Pager "Page X of Y"
|
|
972
|
-
// rendern kann. Bei pagination=false oder "infinite" sparen wir
|
|
973
|
-
// den Roundtrip.
|
|
974
|
-
payload["totalCount"] = true;
|
|
975
|
-
} else if (useInfinite && cursor !== undefined) {
|
|
976
|
-
payload["cursor"] = cursor;
|
|
977
|
-
}
|
|
978
995
|
return payload;
|
|
979
996
|
}, [
|
|
980
997
|
limit,
|
|
@@ -1345,11 +1362,13 @@ function EntityListBody({
|
|
|
1345
1362
|
|
|
1346
1363
|
// ---- projection-list ----
|
|
1347
1364
|
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
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.
|
|
1353
1372
|
function ProjectionListBody({
|
|
1354
1373
|
schema,
|
|
1355
1374
|
screen,
|
|
@@ -1366,9 +1385,44 @@ function ProjectionListBody({
|
|
|
1366
1385
|
const nav = useNav();
|
|
1367
1386
|
const dispatcher = useOptionalDispatcher();
|
|
1368
1387
|
const effectiveTranslate = translate ?? t;
|
|
1369
|
-
|
|
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
|
+
);
|
|
1370
1398
|
const listScreen = useMemo(() => synthesizeProjectionScreen(screen), [screen]);
|
|
1371
|
-
|
|
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 });
|
|
1372
1426
|
|
|
1373
1427
|
const runNavigate = useCallback(
|
|
1374
1428
|
(action: RowActionNavigate, row: ListRowViewModel) => {
|
|
@@ -1515,14 +1569,27 @@ function ProjectionListBody({
|
|
|
1515
1569
|
? (row: ListRowViewModel) => onRowClick(row, listScreen.entity)
|
|
1516
1570
|
: undefined;
|
|
1517
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
|
+
|
|
1518
1581
|
return (
|
|
1519
1582
|
<RenderList
|
|
1520
1583
|
screen={listScreen}
|
|
1521
1584
|
entity={entity}
|
|
1522
1585
|
rows={rowsQuery.data?.rows ?? []}
|
|
1523
1586
|
featureName={schema.featureName}
|
|
1524
|
-
searchable={
|
|
1525
|
-
|
|
1587
|
+
searchable={searchable}
|
|
1588
|
+
searchValue={urlState.q}
|
|
1589
|
+
onSearchChange={urlState.setQ}
|
|
1590
|
+
sort={activeSort}
|
|
1591
|
+
onSortChange={urlState.setSort}
|
|
1592
|
+
{...(pager !== undefined && { pager })}
|
|
1526
1593
|
{...(rowActions !== undefined && { rowActions })}
|
|
1527
1594
|
{...(toolbarActions !== undefined && { toolbarActions })}
|
|
1528
1595
|
{...(translate !== undefined && { translate })}
|
|
@@ -1548,7 +1615,10 @@ function ProjectionDetailBody({
|
|
|
1548
1615
|
entityId,
|
|
1549
1616
|
}: {
|
|
1550
1617
|
readonly schema: FeatureSchema;
|
|
1551
|
-
|
|
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 };
|
|
1552
1622
|
readonly translate?: Translate;
|
|
1553
1623
|
readonly entityId?: string;
|
|
1554
1624
|
}): ReactNode {
|
|
@@ -1569,6 +1639,144 @@ function ProjectionDetailBody({
|
|
|
1569
1639
|
nav.navigate({ screenId: screen.listScreenId });
|
|
1570
1640
|
}, [nav, screen.listScreenId]);
|
|
1571
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
|
+
|
|
1572
1780
|
if (entityId === undefined) {
|
|
1573
1781
|
return (
|
|
1574
1782
|
<Banner padded variant="error" testId="kumiko-screen-projection-detail-missing-id">
|
|
@@ -1608,6 +1816,7 @@ function ProjectionDetailBody({
|
|
|
1608
1816
|
entityId={entityId}
|
|
1609
1817
|
customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
|
|
1610
1818
|
onCancel={screen.listScreenId !== undefined ? navigateToList : undefined}
|
|
1819
|
+
{...(headerActions !== undefined && { actions: headerActions })}
|
|
1611
1820
|
{...(translate !== undefined && { translate })}
|
|
1612
1821
|
/>
|
|
1613
1822
|
);
|
package/src/app/nav.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createContext, type ReactNode, useContext } from "react";
|
|
2
|
+
import type { FeatureSchema } from "./feature-schema";
|
|
3
|
+
import { qualifyScreenId } from "./qualify-screen-id";
|
|
2
4
|
|
|
3
5
|
// Navigation-Contract, plattform-neutral. Types + Context + Hook leben
|
|
4
6
|
// hier; die konkrete Implementation (window.history im Web,
|
|
@@ -20,7 +22,7 @@ export type NavRoute = {
|
|
|
20
22
|
readonly entityId?: string;
|
|
21
23
|
};
|
|
22
24
|
|
|
23
|
-
export type
|
|
25
|
+
export type ScreenTarget = {
|
|
24
26
|
// Optional in workspace-aware navigate calls. Omit for cross-workspace
|
|
25
27
|
// navigation (current workspace stays); set to switch workspaces in the
|
|
26
28
|
// same call as picking a screen — e.g. WorkspaceSwitcher does this.
|
|
@@ -29,6 +31,23 @@ export type NavTarget = {
|
|
|
29
31
|
readonly entityId?: string;
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
// Object-based nav target — callers name WHAT they want to see (an entity's
|
|
35
|
+
// row) instead of WHICH screen shows it. resolveTarget() below turns this
|
|
36
|
+
// into a ScreenTarget by looking up the screen with matching `detailFor`.
|
|
37
|
+
// No edit-form resolution here: an entity can have several entityEdit
|
|
38
|
+
// screens (e.g. solon's "property" has three), so "the" edit screen isn't
|
|
39
|
+
// well-defined — callers that want a form still name its screenId directly.
|
|
40
|
+
export type ObjectTarget = {
|
|
41
|
+
readonly workspaceId?: string;
|
|
42
|
+
readonly entity: string;
|
|
43
|
+
readonly id: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// No `kind` tag: `"screenId" in target` narrows cleanly, and a tag would
|
|
47
|
+
// force every existing NavTarget literal (every navigate/hrefFor call in
|
|
48
|
+
// every app) to add one for an additive ticket. See resolveTarget().
|
|
49
|
+
export type NavTarget = ScreenTarget | ObjectTarget;
|
|
50
|
+
|
|
32
51
|
export type NavApi = {
|
|
33
52
|
/** Current route — `undefined` when the URL is at the root / there's
|
|
34
53
|
* no route selected. Caller's initial fallback kicks in then. */
|
|
@@ -89,7 +108,7 @@ export function parsePath(pathname: string, hasWorkspaces?: boolean): NavRoute |
|
|
|
89
108
|
return { screenId, ...(entityId !== undefined && { entityId }) };
|
|
90
109
|
}
|
|
91
110
|
|
|
92
|
-
export function formatPath(target:
|
|
111
|
+
export function formatPath(target: ScreenTarget): string {
|
|
93
112
|
// Workspace-Mode: prefix the workspace short id. Order matters —
|
|
94
113
|
// workspace before screen mirrors parsePath's segment order.
|
|
95
114
|
const segments: string[] = [];
|
|
@@ -99,6 +118,28 @@ export function formatPath(target: NavTarget): string {
|
|
|
99
118
|
return `/${segments.join("/")}`;
|
|
100
119
|
}
|
|
101
120
|
|
|
121
|
+
// Resolves a NavTarget to the ScreenTarget form navigate/replace/hrefFor
|
|
122
|
+
// operate on. Pure — no context, no I/O — so callers can use it outside
|
|
123
|
+
// React too (see resolve-at-NavApi-build alternative in renderer-web).
|
|
124
|
+
export function resolveTarget(features: readonly FeatureSchema[], target: NavTarget): ScreenTarget {
|
|
125
|
+
if ("screenId" in target) return target;
|
|
126
|
+
|
|
127
|
+
for (const feature of features) {
|
|
128
|
+
for (const screen of feature.screens) {
|
|
129
|
+
if (screen.detailFor !== target.entity) continue;
|
|
130
|
+
return {
|
|
131
|
+
...(target.workspaceId !== undefined && { workspaceId: target.workspaceId }),
|
|
132
|
+
screenId: qualifyScreenId(feature.featureName, screen.id),
|
|
133
|
+
entityId: target.id,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(
|
|
139
|
+
`resolveTarget: no detail screen for entity "${target.entity}". Add detailFor: "${target.entity}" to the screen that shows it.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
102
143
|
// Context + Hook. Default ist `undefined` damit fehlender Provider
|
|
103
144
|
// laut kracht statt ein silent-no-op NavApi mit toten navigate()
|
|
104
145
|
// Aufrufen anzubieten.
|