@cosmicdrift/kumiko-renderer 0.202.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__/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 +250 -33
- package/src/app/projection-list-shim.ts +12 -7
- package/src/components/render-edit.tsx +102 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.203.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.203.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.203.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// fw#2165: buildListQueryPayload was extracted out of EntityListBody's
|
|
2
|
+
// queryPayload useMemo so ProjectionListBody could reuse it without
|
|
3
|
+
// duplicating the branch logic. This pins the extracted function against
|
|
4
|
+
// exactly what EntityListBody built before the extraction — the regression
|
|
5
|
+
// floor both list types now sit on.
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { buildListQueryPayload } from "../kumiko-screen";
|
|
8
|
+
|
|
9
|
+
const base = {
|
|
10
|
+
limit: 50,
|
|
11
|
+
search: "",
|
|
12
|
+
sort: null,
|
|
13
|
+
usePager: false,
|
|
14
|
+
page: 1,
|
|
15
|
+
useInfinite: false,
|
|
16
|
+
cursor: undefined,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
describe("buildListQueryPayload", () => {
|
|
20
|
+
test("bare state: only limit, no search/sort/pager/infinite keys", () => {
|
|
21
|
+
expect(buildListQueryPayload(base)).toEqual({ limit: 50 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("empty search term is omitted, not sent as an empty string", () => {
|
|
25
|
+
expect(buildListQueryPayload({ ...base, search: "" })).toEqual({ limit: 50 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("non-empty search lands in the payload", () => {
|
|
29
|
+
expect(buildListQueryPayload({ ...base, search: "acme" })).toEqual({
|
|
30
|
+
limit: 50,
|
|
31
|
+
search: "acme",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("no sort: neither sort nor sortDirection appear", () => {
|
|
36
|
+
expect(buildListQueryPayload({ ...base, sort: null })).toEqual({ limit: 50 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("sort carries both field and direction", () => {
|
|
40
|
+
expect(buildListQueryPayload({ ...base, sort: { field: "createdAt", dir: "desc" } })).toEqual({
|
|
41
|
+
limit: 50,
|
|
42
|
+
sort: "createdAt",
|
|
43
|
+
sortDirection: "desc",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("pager mode, page 1: totalCount is sent, offset is omitted (not offset: 0)", () => {
|
|
48
|
+
expect(buildListQueryPayload({ ...base, usePager: true, page: 1 })).toEqual({
|
|
49
|
+
limit: 50,
|
|
50
|
+
totalCount: true,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("pager mode, page 3: offset is (page - 1) * limit", () => {
|
|
55
|
+
expect(buildListQueryPayload({ ...base, usePager: true, page: 3 })).toEqual({
|
|
56
|
+
limit: 50,
|
|
57
|
+
offset: 100,
|
|
58
|
+
totalCount: true,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("infinite mode without a cursor yet: no cursor key", () => {
|
|
63
|
+
expect(buildListQueryPayload({ ...base, useInfinite: true, cursor: undefined })).toEqual({
|
|
64
|
+
limit: 50,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("infinite mode with a cursor: cursor lands in the payload", () => {
|
|
69
|
+
expect(buildListQueryPayload({ ...base, useInfinite: true, cursor: "row-42" })).toEqual({
|
|
70
|
+
limit: 50,
|
|
71
|
+
cursor: "row-42",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("pager wins over infinite when both flags are true (usePager gates first)", () => {
|
|
76
|
+
expect(
|
|
77
|
+
buildListQueryPayload({ ...base, usePager: true, useInfinite: true, cursor: "row-42" }),
|
|
78
|
+
).toEqual({ limit: 50, totalCount: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("pagination=false (neither pager nor infinite): no pagination keys at all", () => {
|
|
82
|
+
expect(
|
|
83
|
+
buildListQueryPayload({ ...base, usePager: false, useInfinite: false, cursor: "row-42" }),
|
|
84
|
+
).toEqual({ limit: 50 });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("full combination: search + sort + pager", () => {
|
|
88
|
+
expect(
|
|
89
|
+
buildListQueryPayload({
|
|
90
|
+
limit: 25,
|
|
91
|
+
search: "acme",
|
|
92
|
+
sort: { field: "name", dir: "asc" },
|
|
93
|
+
usePager: true,
|
|
94
|
+
page: 2,
|
|
95
|
+
useInfinite: false,
|
|
96
|
+
cursor: undefined,
|
|
97
|
+
}),
|
|
98
|
+
).toEqual({
|
|
99
|
+
limit: 25,
|
|
100
|
+
search: "acme",
|
|
101
|
+
sort: "name",
|
|
102
|
+
sortDirection: "asc",
|
|
103
|
+
offset: 25,
|
|
104
|
+
totalCount: true,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// fw#2166: projectionDetail screens can declare header `actions`, and get a
|
|
2
|
+
// default "edit" action for free when `detailFor` names an entity that has a
|
|
3
|
+
// visible entityEdit screen somewhere in the app (not just this feature —
|
|
4
|
+
// the motivating case is a projectionDetail whose query belongs to one
|
|
5
|
+
// feature while the entity's entityEdit screen lives in another). This
|
|
6
|
+
// renders the real path (KumikoScreen → ProjectionDetailBody → RenderEdit)
|
|
7
|
+
// under a stub dispatcher + AppFeaturesProvider, and proves the default-edit
|
|
8
|
+
// resolution rules from the boot-validator's detailFor doc (cross-feature
|
|
9
|
+
// lookup, access-gating, id: "edit" declared-action suppression).
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import type {
|
|
13
|
+
AccessRule,
|
|
14
|
+
EntityEditScreenDefinition,
|
|
15
|
+
ProjectionDetailScreenDefinition,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
18
|
+
import { act, fireEvent, render, waitFor } from "@testing-library/react";
|
|
19
|
+
import type { ComponentType, ReactNode } from "react";
|
|
20
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
21
|
+
import { UserRolesProvider } from "../../context/user-roles-context";
|
|
22
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
23
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
24
|
+
import {
|
|
25
|
+
type BannerProps,
|
|
26
|
+
type ButtonProps,
|
|
27
|
+
type CorePrimitives,
|
|
28
|
+
type FormProps,
|
|
29
|
+
PrimitivesProvider,
|
|
30
|
+
} from "../../primitives";
|
|
31
|
+
import { AppFeaturesProvider } from "../app-features-context";
|
|
32
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
33
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
34
|
+
import type { NavApi, ScreenTarget } from "../nav";
|
|
35
|
+
import { NavProvider } from "../nav";
|
|
36
|
+
|
|
37
|
+
const noop = (): ReactNode => null;
|
|
38
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
39
|
+
|
|
40
|
+
const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) => (
|
|
41
|
+
<button type="button" data-testid={testId} onClick={() => void onClick?.()}>
|
|
42
|
+
{children}
|
|
43
|
+
</button>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Form's `actions` slot carries the header buttons under test — passChildren
|
|
47
|
+
// alone would drop it. Wrapped in distinct testid'd containers so a test can
|
|
48
|
+
// assert a banner rendered as a Form CHILD (formError/actionError region)
|
|
49
|
+
// rather than inside the actions row — the review finding this proves.
|
|
50
|
+
const FormWithActions: ComponentType<FormProps> = ({ children, actions }) => (
|
|
51
|
+
<>
|
|
52
|
+
<div data-testid="form-body">{children}</div>
|
|
53
|
+
<div data-testid="form-actions">{actions}</div>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// passChildren would drop testId — this test asserts on Banner structure/
|
|
58
|
+
// placement, so it needs a real (if minimal) wrapper element.
|
|
59
|
+
const TestBanner: ComponentType<BannerProps> = ({ children, testId }) => (
|
|
60
|
+
<div data-testid={testId}>{children}</div>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const testPrimitives: CorePrimitives = {
|
|
64
|
+
Button: TestButton,
|
|
65
|
+
Banner: TestBanner,
|
|
66
|
+
Field: passChildren,
|
|
67
|
+
Input: noop,
|
|
68
|
+
DataTable: noop,
|
|
69
|
+
Form: FormWithActions,
|
|
70
|
+
Section: passChildren,
|
|
71
|
+
Card: passChildren,
|
|
72
|
+
Grid: passChildren,
|
|
73
|
+
GridCell: passChildren,
|
|
74
|
+
Text: passChildren,
|
|
75
|
+
Heading: noop,
|
|
76
|
+
Dialog: noop,
|
|
77
|
+
Modal: noop,
|
|
78
|
+
Lightbox: noop,
|
|
79
|
+
ConfigSourceBadge: noop,
|
|
80
|
+
ConfigCascadeView: noop,
|
|
81
|
+
Link: noop,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function stubDispatcher(
|
|
85
|
+
record: Readonly<Record<string, unknown>>,
|
|
86
|
+
writeErrorMessage?: string,
|
|
87
|
+
): Dispatcher {
|
|
88
|
+
const writeResult =
|
|
89
|
+
writeErrorMessage !== undefined
|
|
90
|
+
? {
|
|
91
|
+
isSuccess: false,
|
|
92
|
+
error: {
|
|
93
|
+
code: "write-failed",
|
|
94
|
+
httpStatus: 500,
|
|
95
|
+
i18nKey: "kumiko.errors.does-not-exist-in-any-bundle",
|
|
96
|
+
message: writeErrorMessage,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
: { isSuccess: true, data: {} };
|
|
100
|
+
return {
|
|
101
|
+
write: (async () => writeResult) as unknown as Dispatcher["write"],
|
|
102
|
+
query: (async () => ({ isSuccess: true, data: record })) as unknown as Dispatcher["query"],
|
|
103
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
104
|
+
statusStore: {
|
|
105
|
+
getState: () => "online",
|
|
106
|
+
subscribe: () => () => {},
|
|
107
|
+
} as unknown as Dispatcher["statusStore"],
|
|
108
|
+
async *stream() {},
|
|
109
|
+
pendingWrites: () => [],
|
|
110
|
+
pendingFiles: () => [],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function detailScreen(
|
|
115
|
+
overrides?: Partial<ProjectionDetailScreenDefinition & { readonly detailFor?: string }>,
|
|
116
|
+
): FeatureSchema["screens"][number] {
|
|
117
|
+
return {
|
|
118
|
+
id: "rent-detail",
|
|
119
|
+
type: "projectionDetail",
|
|
120
|
+
query: "app:query:rent:detail",
|
|
121
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
122
|
+
detailFor: "rent",
|
|
123
|
+
...overrides,
|
|
124
|
+
} as FeatureSchema["screens"][number];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Registry-qualified id ("<feature>:screen:<short>"), matching real schema
|
|
128
|
+
// output — a bare short id here would hide the QN-vs-short-form bug this
|
|
129
|
+
// test suite exists to catch (fw#2166 review finding 1/4).
|
|
130
|
+
function editScreen(
|
|
131
|
+
entity: string,
|
|
132
|
+
access?: AccessRule,
|
|
133
|
+
featureName = "app",
|
|
134
|
+
): EntityEditScreenDefinition {
|
|
135
|
+
return {
|
|
136
|
+
id: `${featureName}:screen:rent-edit`,
|
|
137
|
+
type: "entityEdit",
|
|
138
|
+
entity,
|
|
139
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
140
|
+
...(access !== undefined && { access }),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderDetail(opts: {
|
|
145
|
+
readonly primarySchema: FeatureSchema;
|
|
146
|
+
readonly features: readonly FeatureSchema[];
|
|
147
|
+
readonly userRoles?: readonly string[];
|
|
148
|
+
readonly record?: Readonly<Record<string, unknown>>;
|
|
149
|
+
readonly onNavigate?: (target: ScreenTarget) => void;
|
|
150
|
+
readonly writeErrorMessage?: string;
|
|
151
|
+
}): ReturnType<typeof render> {
|
|
152
|
+
const navApi: NavApi = {
|
|
153
|
+
route: { screenId: "app:screen:rent-detail" },
|
|
154
|
+
// Header actions only ever navigate with a ScreenTarget (screenId +
|
|
155
|
+
// entityId) — same narrowing idiom as nav.tsx's resolveTarget.
|
|
156
|
+
navigate: (target) => {
|
|
157
|
+
if ("screenId" in target) opts.onNavigate?.(target);
|
|
158
|
+
},
|
|
159
|
+
replace: () => {},
|
|
160
|
+
hrefFor: () => "",
|
|
161
|
+
searchParams: {},
|
|
162
|
+
setSearchParams: () => {},
|
|
163
|
+
};
|
|
164
|
+
return render(
|
|
165
|
+
<LocaleProvider
|
|
166
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
167
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
168
|
+
>
|
|
169
|
+
<DispatcherProvider
|
|
170
|
+
dispatcher={stubDispatcher(
|
|
171
|
+
opts.record ?? { id: "rent-1", description: "Rent for April" },
|
|
172
|
+
opts.writeErrorMessage,
|
|
173
|
+
)}
|
|
174
|
+
>
|
|
175
|
+
<AppFeaturesProvider features={opts.features}>
|
|
176
|
+
<UserRolesProvider roles={opts.userRoles}>
|
|
177
|
+
<NavProvider value={navApi}>
|
|
178
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
179
|
+
<KumikoScreen
|
|
180
|
+
schema={opts.primarySchema}
|
|
181
|
+
qn="app:screen:rent-detail"
|
|
182
|
+
entityId="rent-1"
|
|
183
|
+
/>
|
|
184
|
+
</PrimitivesProvider>
|
|
185
|
+
</NavProvider>
|
|
186
|
+
</UserRolesProvider>
|
|
187
|
+
</AppFeaturesProvider>
|
|
188
|
+
</DispatcherProvider>
|
|
189
|
+
</LocaleProvider>,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
describe("projectionDetail default edit action (fw#2166)", () => {
|
|
194
|
+
test("detailFor + matching entityEdit in the SAME feature → edit button navigates with the record id", async () => {
|
|
195
|
+
const schema: FeatureSchema = {
|
|
196
|
+
featureName: "app",
|
|
197
|
+
entities: {},
|
|
198
|
+
screens: [detailScreen(), editScreen("rent")],
|
|
199
|
+
};
|
|
200
|
+
let navigated: ScreenTarget | undefined;
|
|
201
|
+
const { getByTestId, queryByText } = renderDetail({
|
|
202
|
+
primarySchema: schema,
|
|
203
|
+
features: [schema],
|
|
204
|
+
userRoles: [],
|
|
205
|
+
onNavigate: (target) => {
|
|
206
|
+
navigated = target;
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
210
|
+
|
|
211
|
+
// trigger() is async (busy-state bookkeeping around onPress), so the
|
|
212
|
+
// click's state updates land after this tick — act() flushes them.
|
|
213
|
+
await act(async () => {
|
|
214
|
+
fireEvent.click(getByTestId("render-edit-action-edit"));
|
|
215
|
+
});
|
|
216
|
+
expect(navigated).toEqual({ screenId: "rent-edit", entityId: "rent-1" });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("edit screen access denied → no edit button", async () => {
|
|
220
|
+
const schema: FeatureSchema = {
|
|
221
|
+
featureName: "app",
|
|
222
|
+
entities: {},
|
|
223
|
+
screens: [detailScreen(), editScreen("rent", { roles: ["admin"] })],
|
|
224
|
+
};
|
|
225
|
+
const { queryByTestId, queryByText } = renderDetail({
|
|
226
|
+
primarySchema: schema,
|
|
227
|
+
features: [schema],
|
|
228
|
+
userRoles: [],
|
|
229
|
+
});
|
|
230
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
231
|
+
|
|
232
|
+
expect(queryByTestId("render-edit-action-edit")).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("no entityEdit screen for the detailFor entity → no edit button", async () => {
|
|
236
|
+
const schema: FeatureSchema = {
|
|
237
|
+
featureName: "app",
|
|
238
|
+
entities: {},
|
|
239
|
+
screens: [detailScreen()],
|
|
240
|
+
};
|
|
241
|
+
const { queryByTestId, queryByText } = renderDetail({
|
|
242
|
+
primarySchema: schema,
|
|
243
|
+
features: [schema],
|
|
244
|
+
userRoles: [],
|
|
245
|
+
});
|
|
246
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
247
|
+
|
|
248
|
+
expect(queryByTestId("render-edit-action-edit")).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('a declared actions entry with id: "edit" wins — exactly one edit button, the declared one', async () => {
|
|
252
|
+
const schema: FeatureSchema = {
|
|
253
|
+
featureName: "app",
|
|
254
|
+
entities: {},
|
|
255
|
+
screens: [
|
|
256
|
+
detailScreen({
|
|
257
|
+
actions: [
|
|
258
|
+
{ kind: "navigate", id: "edit", label: "custom-edit-label", screen: "rent-edit" },
|
|
259
|
+
],
|
|
260
|
+
}),
|
|
261
|
+
editScreen("rent"),
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
const { getAllByTestId, queryByText } = renderDetail({
|
|
265
|
+
primarySchema: schema,
|
|
266
|
+
features: [schema],
|
|
267
|
+
userRoles: [],
|
|
268
|
+
});
|
|
269
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
270
|
+
|
|
271
|
+
const editButtons = getAllByTestId("render-edit-action-edit");
|
|
272
|
+
expect(editButtons).toHaveLength(1);
|
|
273
|
+
// "custom-edit-label" has no translation registered, so translate()
|
|
274
|
+
// returns the raw key — proving this is the declared action, not the
|
|
275
|
+
// default (which renders the translated "kumiko.actions.edit").
|
|
276
|
+
expect(editButtons[0]?.textContent).toBe("custom-edit-label");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("two entityEdit screens for the same entity, only the second accessible → button still renders, targeting the second (review finding 2)", async () => {
|
|
280
|
+
const lockedEdit: EntityEditScreenDefinition = {
|
|
281
|
+
id: "app:screen:rent-edit-locked",
|
|
282
|
+
type: "entityEdit",
|
|
283
|
+
entity: "rent",
|
|
284
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
285
|
+
access: { roles: ["admin"] },
|
|
286
|
+
};
|
|
287
|
+
const openEdit: EntityEditScreenDefinition = {
|
|
288
|
+
id: "app:screen:rent-edit-open",
|
|
289
|
+
type: "entityEdit",
|
|
290
|
+
entity: "rent",
|
|
291
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
292
|
+
};
|
|
293
|
+
const schema: FeatureSchema = {
|
|
294
|
+
featureName: "app",
|
|
295
|
+
entities: {},
|
|
296
|
+
screens: [detailScreen(), lockedEdit, openEdit],
|
|
297
|
+
};
|
|
298
|
+
let navigated: ScreenTarget | undefined;
|
|
299
|
+
const { getByTestId, queryByText } = renderDetail({
|
|
300
|
+
primarySchema: schema,
|
|
301
|
+
features: [schema],
|
|
302
|
+
userRoles: [],
|
|
303
|
+
onNavigate: (target) => {
|
|
304
|
+
navigated = target;
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
308
|
+
|
|
309
|
+
await act(async () => {
|
|
310
|
+
fireEvent.click(getByTestId("render-edit-action-edit"));
|
|
311
|
+
});
|
|
312
|
+
expect(navigated).toEqual({ screenId: "rent-edit-open", entityId: "rent-1" });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("declared navigate action without entityId, targeting an entityEdit of the SAME entity → auto-fills the record id (review finding 3)", async () => {
|
|
316
|
+
const schema: FeatureSchema = {
|
|
317
|
+
featureName: "app",
|
|
318
|
+
entities: {},
|
|
319
|
+
screens: [
|
|
320
|
+
detailScreen({
|
|
321
|
+
actions: [
|
|
322
|
+
{
|
|
323
|
+
kind: "navigate",
|
|
324
|
+
id: "open-record",
|
|
325
|
+
label: "actions.openRecord",
|
|
326
|
+
screen: "rent-edit",
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
}),
|
|
330
|
+
editScreen("rent"),
|
|
331
|
+
],
|
|
332
|
+
};
|
|
333
|
+
let navigated: ScreenTarget | undefined;
|
|
334
|
+
const { getByTestId, queryByText } = renderDetail({
|
|
335
|
+
primarySchema: schema,
|
|
336
|
+
features: [schema],
|
|
337
|
+
userRoles: [],
|
|
338
|
+
onNavigate: (target) => {
|
|
339
|
+
navigated = target;
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
343
|
+
|
|
344
|
+
await act(async () => {
|
|
345
|
+
fireEvent.click(getByTestId("render-edit-action-open-record"));
|
|
346
|
+
});
|
|
347
|
+
expect(navigated).toEqual({ screenId: "rent-edit", entityId: "rent-1" });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("declared action with a non-matching visible condition → no button rendered", async () => {
|
|
351
|
+
const schema: FeatureSchema = {
|
|
352
|
+
featureName: "app",
|
|
353
|
+
entities: {},
|
|
354
|
+
screens: [
|
|
355
|
+
detailScreen({
|
|
356
|
+
actions: [
|
|
357
|
+
{
|
|
358
|
+
kind: "navigate",
|
|
359
|
+
id: "archive",
|
|
360
|
+
label: "actions.archive",
|
|
361
|
+
screen: "rent-edit",
|
|
362
|
+
visible: { field: "status", eq: "closed" },
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
}),
|
|
366
|
+
],
|
|
367
|
+
};
|
|
368
|
+
const { queryByTestId, queryByText } = renderDetail({
|
|
369
|
+
primarySchema: schema,
|
|
370
|
+
features: [schema],
|
|
371
|
+
userRoles: [],
|
|
372
|
+
record: { id: "rent-1", description: "Rent for April", status: "open" },
|
|
373
|
+
});
|
|
374
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
375
|
+
|
|
376
|
+
expect(queryByTestId("render-edit-action-archive")).toBeNull();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("entityEdit screen lives in ANOTHER feature → edit button is still there (cross-feature resolution)", async () => {
|
|
380
|
+
const appSchema: FeatureSchema = {
|
|
381
|
+
featureName: "app",
|
|
382
|
+
entities: {},
|
|
383
|
+
screens: [detailScreen()],
|
|
384
|
+
};
|
|
385
|
+
const billingSchema: FeatureSchema = {
|
|
386
|
+
featureName: "billing",
|
|
387
|
+
entities: {},
|
|
388
|
+
screens: [editScreen("rent", undefined, "billing")],
|
|
389
|
+
};
|
|
390
|
+
let navigated: ScreenTarget | undefined;
|
|
391
|
+
const { getByTestId, queryByText } = renderDetail({
|
|
392
|
+
primarySchema: appSchema,
|
|
393
|
+
features: [appSchema, billingSchema],
|
|
394
|
+
userRoles: [],
|
|
395
|
+
onNavigate: (target) => {
|
|
396
|
+
navigated = target;
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
400
|
+
|
|
401
|
+
// trigger() is async (busy-state bookkeeping around onPress), so the
|
|
402
|
+
// click's state updates land after this tick — act() flushes them.
|
|
403
|
+
await act(async () => {
|
|
404
|
+
fireEvent.click(getByTestId("render-edit-action-edit"));
|
|
405
|
+
});
|
|
406
|
+
expect(navigated).toEqual({ screenId: "rent-edit", entityId: "rent-1" });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("a failed writeHandler action shows its error in the shared error region, NOT inside the action button row", async () => {
|
|
410
|
+
const schema: FeatureSchema = {
|
|
411
|
+
featureName: "app",
|
|
412
|
+
entities: {},
|
|
413
|
+
screens: [
|
|
414
|
+
detailScreen({
|
|
415
|
+
actions: [
|
|
416
|
+
{
|
|
417
|
+
kind: "writeHandler",
|
|
418
|
+
id: "archive",
|
|
419
|
+
label: "actions.archive",
|
|
420
|
+
handler: "app:write:archive",
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
}),
|
|
424
|
+
],
|
|
425
|
+
};
|
|
426
|
+
const { getByTestId, queryByText } = renderDetail({
|
|
427
|
+
primarySchema: schema,
|
|
428
|
+
features: [schema],
|
|
429
|
+
userRoles: [],
|
|
430
|
+
writeErrorMessage: "archive failed: rent is still active",
|
|
431
|
+
});
|
|
432
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
433
|
+
|
|
434
|
+
await act(async () => {
|
|
435
|
+
fireEvent.click(getByTestId("render-edit-action-archive"));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const errorBanner = await waitFor(() => getByTestId("render-edit-action-error"));
|
|
439
|
+
expect(errorBanner.textContent).toBe("archive failed: rent is still active");
|
|
440
|
+
// The banner must be a Form child (formError-adjacent region), not a
|
|
441
|
+
// descendant of the actions row — a full-width Banner inside the
|
|
442
|
+
// `justify-end` button row breaks its layout (fw#2166 review finding 5).
|
|
443
|
+
expect(errorBanner.closest('[data-testid="form-body"]')).not.toBeNull();
|
|
444
|
+
expect(errorBanner.closest('[data-testid="form-actions"]')).toBeNull();
|
|
445
|
+
expect(
|
|
446
|
+
getByTestId("render-edit-action-archive").closest('[data-testid="form-actions"]'),
|
|
447
|
+
).not.toBeNull();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
@@ -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
|
-
//
|
|
939
|
-
//
|
|
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
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
//
|
|
1341
|
-
//
|
|
1342
|
-
//
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
1517
|
-
|
|
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
|
-
|
|
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
|
);
|
|
@@ -24,14 +24,19 @@ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
|
24
24
|
|
|
25
25
|
const PROJECTION_PSEUDO_ENTITY = "__projection__";
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
39
|
+
fields[normalizeListColumn(col).field] = { type: "text", sortable };
|
|
35
40
|
}
|
|
36
41
|
return { fields } as unknown as EntityDefinition;
|
|
37
42
|
}
|
|
@@ -136,6 +136,12 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
136
136
|
* platform-neutrale Package darf kein `navigator`/`window` anfassen,
|
|
137
137
|
* siehe guard-renderer-boundaries). undefined = kein Button. */
|
|
138
138
|
readonly onCopyLink?: () => Promise<void> | void;
|
|
139
|
+
/** Header action buttons, rendered before the built-in copy-link/
|
|
140
|
+
* delete/cancel/save controls. Each callback is already fully
|
|
141
|
+
* bound (screen-type/nav/dispatcher resolution happens in the caller,
|
|
142
|
+
* same split as `onCopyLink`) — RenderEdit only wires the button, its
|
|
143
|
+
* busy state and its confirm dialog. */
|
|
144
|
+
readonly actions?: readonly RenderEditAction[];
|
|
139
145
|
/** i18n-key für den Submit-Button. Default: "kumiko.actions.save".
|
|
140
146
|
* Action-Forms (Tier 2.7d) übergeben hier ihren screen.submitLabel,
|
|
141
147
|
* damit "Speichern" durch domain-spezifischere Strings ersetzt
|
|
@@ -205,6 +211,15 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
205
211
|
readonly hideActions?: boolean;
|
|
206
212
|
};
|
|
207
213
|
|
|
214
|
+
export type RenderEditAction = {
|
|
215
|
+
readonly id: string;
|
|
216
|
+
readonly label: string;
|
|
217
|
+
readonly onPress: () => void | Promise<void>;
|
|
218
|
+
readonly style?: "primary" | "secondary" | "danger";
|
|
219
|
+
readonly confirm?: string;
|
|
220
|
+
readonly confirmLabel?: string;
|
|
221
|
+
};
|
|
222
|
+
|
|
208
223
|
export type RenderEditChangeState<TValues extends FormValues> = {
|
|
209
224
|
readonly values: TValues;
|
|
210
225
|
readonly changes: Partial<TValues>;
|
|
@@ -314,6 +329,72 @@ function ExtensionSectionMount({
|
|
|
314
329
|
);
|
|
315
330
|
}
|
|
316
331
|
|
|
332
|
+
// One header action + its own busy/confirm state — same pattern as
|
|
333
|
+
// render-list.tsx's ToolbarActionView (each RenderEditAction is
|
|
334
|
+
// independently bound by the caller, there is no shared trigger pipeline
|
|
335
|
+
// to hook into like the built-in onDelete/onSubmit paths have).
|
|
336
|
+
function RenderEditActionButton({
|
|
337
|
+
action,
|
|
338
|
+
Button,
|
|
339
|
+
Dialog,
|
|
340
|
+
onError,
|
|
341
|
+
}: {
|
|
342
|
+
readonly action: RenderEditAction;
|
|
343
|
+
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
344
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
345
|
+
readonly onError: (text: string | null) => void;
|
|
346
|
+
}): ReactNode {
|
|
347
|
+
const [busy, setBusy] = useState(false);
|
|
348
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
349
|
+
|
|
350
|
+
const trigger = async (): Promise<void> => {
|
|
351
|
+
setBusy(true);
|
|
352
|
+
onError(null);
|
|
353
|
+
try {
|
|
354
|
+
await action.onPress();
|
|
355
|
+
} catch (e) {
|
|
356
|
+
onError(e instanceof Error ? e.message : String(e));
|
|
357
|
+
} finally {
|
|
358
|
+
setBusy(false);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const variant = action.style ?? "secondary";
|
|
363
|
+
// Same rule as RowActionWriteHandler: "danger" forces a confirm even
|
|
364
|
+
// without an explicit confirm key.
|
|
365
|
+
const needsConfirm = action.confirm !== undefined || action.style === "danger";
|
|
366
|
+
|
|
367
|
+
return (
|
|
368
|
+
<>
|
|
369
|
+
<Button
|
|
370
|
+
type="button"
|
|
371
|
+
variant={variant}
|
|
372
|
+
loading={busy}
|
|
373
|
+
onClick={() => {
|
|
374
|
+
if (needsConfirm) {
|
|
375
|
+
setConfirmOpen(true);
|
|
376
|
+
} else {
|
|
377
|
+
void trigger();
|
|
378
|
+
}
|
|
379
|
+
}}
|
|
380
|
+
testId={`render-edit-action-${action.id}`}
|
|
381
|
+
>
|
|
382
|
+
{action.label}
|
|
383
|
+
</Button>
|
|
384
|
+
<Dialog
|
|
385
|
+
open={confirmOpen}
|
|
386
|
+
onOpenChange={setConfirmOpen}
|
|
387
|
+
title={action.label}
|
|
388
|
+
{...(action.confirm !== undefined && { description: action.confirm })}
|
|
389
|
+
confirmLabel={action.confirmLabel ?? action.label}
|
|
390
|
+
{...(action.style === "danger" && { variant: "danger" as const })}
|
|
391
|
+
onConfirm={trigger}
|
|
392
|
+
testId={`render-edit-action-${action.id}-dialog`}
|
|
393
|
+
/>
|
|
394
|
+
</>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
317
398
|
export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
318
399
|
props: RenderEditProps<TValues, TCtx>,
|
|
319
400
|
): ReactNode {
|
|
@@ -333,6 +414,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
333
414
|
onCancel,
|
|
334
415
|
onReload,
|
|
335
416
|
onCopyLink,
|
|
417
|
+
actions,
|
|
336
418
|
submitLabel,
|
|
337
419
|
labelAppendix,
|
|
338
420
|
fieldAppendix,
|
|
@@ -362,6 +444,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
362
444
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
363
445
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
364
446
|
const [formError, setFormError] = useState<DispatcherError | null>(null);
|
|
447
|
+
// One state for all header actions (actions?), not per-button — only one
|
|
448
|
+
// action can be in flight at a time, and this keeps the error surfaced in
|
|
449
|
+
// the shared formError-adjacent banner region instead of inside the
|
|
450
|
+
// button row (fw#2166 review: a full-width Banner there breaks the
|
|
451
|
+
// flex justify-end action bar).
|
|
452
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
365
453
|
const [rawStep, setRawStep] = useState(0);
|
|
366
454
|
// Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
|
|
367
455
|
// on mount so a same-tab reload finds the right one of several parallel
|
|
@@ -985,6 +1073,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
985
1073
|
// gegen Fehlklicks. Save bleibt rechts (primary affordance).
|
|
986
1074
|
const formActions = (
|
|
987
1075
|
<>
|
|
1076
|
+
{actions?.map((action) => (
|
|
1077
|
+
<RenderEditActionButton
|
|
1078
|
+
key={action.id}
|
|
1079
|
+
action={action}
|
|
1080
|
+
Button={Button}
|
|
1081
|
+
Dialog={Dialog}
|
|
1082
|
+
onError={setActionError}
|
|
1083
|
+
/>
|
|
1084
|
+
))}
|
|
988
1085
|
{onCopyLink !== undefined && (
|
|
989
1086
|
<Button
|
|
990
1087
|
type="button"
|
|
@@ -1282,6 +1379,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1282
1379
|
<Text testId="render-edit-extension-error-key">{translate(extensionErrorKey)}</Text>
|
|
1283
1380
|
</Banner>
|
|
1284
1381
|
)}
|
|
1382
|
+
{actionError !== null && (
|
|
1383
|
+
<Banner variant="error" testId="render-edit-action-error">
|
|
1384
|
+
{actionError}
|
|
1385
|
+
</Banner>
|
|
1386
|
+
)}
|
|
1285
1387
|
{onDelete !== undefined && (
|
|
1286
1388
|
<Dialog
|
|
1287
1389
|
open={confirmDeleteOpen}
|
package/src/index.ts
CHANGED
|
@@ -78,6 +78,7 @@ export type { VariableChipsProps } from "./app/variable-chips";
|
|
|
78
78
|
export { VariableChips } from "./app/variable-chips";
|
|
79
79
|
export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error";
|
|
80
80
|
export type {
|
|
81
|
+
RenderEditAction,
|
|
81
82
|
RenderEditChangeState,
|
|
82
83
|
RenderEditControls,
|
|
83
84
|
RenderEditProps,
|