@cosmicdrift/kumiko-renderer 0.259.0 → 0.260.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/app/__tests__/action-form-shim.test.ts +21 -0
- package/src/app/__tests__/row-default-edit-action.test.tsx +324 -0
- package/src/app/action-form-shim.ts +3 -0
- package/src/app/extension-sections.tsx +9 -0
- package/src/app/kumiko-screen.tsx +170 -86
- package/src/app/row-actions.ts +71 -29
- package/src/app/screen-access.ts +22 -1
- package/src/components/__tests__/related-list-section.test.tsx +119 -1
- package/src/components/related-list-section.tsx +29 -2
- package/src/components/render-edit.tsx +87 -2
- package/src/primitives.tsx +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.260.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.260.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.260.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
28
|
"jsdom": "^29.1.1",
|
|
29
29
|
"react-dom": "^19.2.6",
|
|
30
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.260.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
@@ -48,6 +48,27 @@ describe("synthesizeActionFormScreen", () => {
|
|
|
48
48
|
});
|
|
49
49
|
expect("description" in withoutDescription).toBe(false);
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
test("carries slots through so RenderEdit can mount header/footer slots on an actionForm", () => {
|
|
53
|
+
const withSlots = synthesizeActionFormScreen({
|
|
54
|
+
id: "invite-user",
|
|
55
|
+
type: "actionForm",
|
|
56
|
+
handler: "users:write:invite-user",
|
|
57
|
+
layout: { sections: [{ title: "Invite", fields: ["email"] }] },
|
|
58
|
+
fields: { email: { type: "text" } },
|
|
59
|
+
slots: { footer: { react: { __component: "f" } } },
|
|
60
|
+
});
|
|
61
|
+
expect(withSlots.slots).toEqual({ footer: { react: { __component: "f" } } });
|
|
62
|
+
|
|
63
|
+
const withoutSlots = synthesizeActionFormScreen({
|
|
64
|
+
id: "invite-user",
|
|
65
|
+
type: "actionForm",
|
|
66
|
+
handler: "users:write:invite-user",
|
|
67
|
+
layout: { sections: [{ title: "Invite", fields: ["email"] }] },
|
|
68
|
+
fields: { email: { type: "text" } },
|
|
69
|
+
});
|
|
70
|
+
expect("slots" in withoutSlots).toBe(false);
|
|
71
|
+
});
|
|
51
72
|
});
|
|
52
73
|
|
|
53
74
|
describe("synthesizeSecretMintConfirmScreen (fw#2838)", () => {
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// kumiko-screen-akte-bedienkonzept: entityList and projectionList rowActions
|
|
2
|
+
// get a default "Edit" row action for free when the row's entity has a
|
|
3
|
+
// visible entityEdit screen — same cross-feature resolution as
|
|
4
|
+
// projectionDetail's header defaultEditAction (fw#2166), reused here via
|
|
5
|
+
// findEditScreenFor (screen-access.ts). Declared rowActions with id "edit"
|
|
6
|
+
// win; access-gating and a missing entityEdit screen both suppress the
|
|
7
|
+
// default. Mirrors entity-list-row-action-entity-target.test.tsx's harness.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import type {
|
|
11
|
+
EntityDefinition,
|
|
12
|
+
EntityEditScreenDefinition,
|
|
13
|
+
EntityListScreenDefinition,
|
|
14
|
+
ProjectionListScreenDefinition,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
16
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
17
|
+
import { render, waitFor } from "@testing-library/react";
|
|
18
|
+
import type { ComponentType, ReactNode } from "react";
|
|
19
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
20
|
+
import { UserRolesProvider } from "../../context/user-roles-context";
|
|
21
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
22
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
23
|
+
import {
|
|
24
|
+
type CorePrimitives,
|
|
25
|
+
type DataTableProps,
|
|
26
|
+
type DataTableRowAction,
|
|
27
|
+
PrimitivesProvider,
|
|
28
|
+
} from "../../primitives";
|
|
29
|
+
import { AppFeaturesProvider } from "../app-features-context";
|
|
30
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
31
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
32
|
+
import type { NavTarget } from "../nav";
|
|
33
|
+
import { NavProvider } from "../nav";
|
|
34
|
+
|
|
35
|
+
function stubDispatcher(rows: readonly Record<string, unknown>[]): Dispatcher {
|
|
36
|
+
return {
|
|
37
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
38
|
+
query: (async () => ({
|
|
39
|
+
isSuccess: true,
|
|
40
|
+
data: { rows, nextCursor: null, total: rows.length },
|
|
41
|
+
})) as unknown as Dispatcher["query"],
|
|
42
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
43
|
+
statusStore: {
|
|
44
|
+
getState: () => "online",
|
|
45
|
+
subscribe: () => () => {},
|
|
46
|
+
} as unknown as Dispatcher["statusStore"],
|
|
47
|
+
async *stream() {},
|
|
48
|
+
pendingWrites: () => [],
|
|
49
|
+
pendingFiles: () => [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Captured = {
|
|
54
|
+
readonly rowActions: readonly DataTableRowAction[] | undefined;
|
|
55
|
+
readonly rowCount: number;
|
|
56
|
+
};
|
|
57
|
+
let captured: Captured = { rowActions: undefined, rowCount: 0 };
|
|
58
|
+
const captureDataTable: ComponentType<DataTableProps> = (props) => {
|
|
59
|
+
captured = { rowActions: props.rowActions, rowCount: props.rows.length };
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
const noop = (): ReactNode => null;
|
|
63
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
64
|
+
|
|
65
|
+
const testPrimitives: CorePrimitives = {
|
|
66
|
+
Button: noop,
|
|
67
|
+
Banner: passChildren,
|
|
68
|
+
Field: passChildren,
|
|
69
|
+
Input: noop,
|
|
70
|
+
DataTable: captureDataTable,
|
|
71
|
+
Form: passChildren,
|
|
72
|
+
Section: passChildren,
|
|
73
|
+
Card: passChildren,
|
|
74
|
+
Grid: passChildren,
|
|
75
|
+
GridCell: passChildren,
|
|
76
|
+
Text: passChildren,
|
|
77
|
+
Heading: noop,
|
|
78
|
+
Dialog: noop,
|
|
79
|
+
Modal: noop,
|
|
80
|
+
Lightbox: noop,
|
|
81
|
+
ConfigSourceBadge: noop,
|
|
82
|
+
ConfigCascadeView: noop,
|
|
83
|
+
Link: noop,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function editScreen(entity: string, roles?: readonly string[]): EntityEditScreenDefinition {
|
|
87
|
+
return {
|
|
88
|
+
id: "app:screen:rent-edit",
|
|
89
|
+
type: "entityEdit",
|
|
90
|
+
entity,
|
|
91
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
92
|
+
...(roles !== undefined && { access: { roles } }),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requireAction(id: string): DataTableRowAction {
|
|
97
|
+
const action = captured.rowActions?.find((a) => a.id === id);
|
|
98
|
+
if (!action) throw new Error(`expected the '${id}' row action to be captured`);
|
|
99
|
+
return action;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderScreen(
|
|
103
|
+
schema: FeatureSchema,
|
|
104
|
+
qn: string,
|
|
105
|
+
navigateSpy: (target: NavTarget) => void,
|
|
106
|
+
userRoles: readonly string[],
|
|
107
|
+
): void {
|
|
108
|
+
render(
|
|
109
|
+
<LocaleProvider
|
|
110
|
+
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
111
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
112
|
+
>
|
|
113
|
+
<DispatcherProvider dispatcher={stubDispatcher([{ id: "row-1", name: "Alice" }])}>
|
|
114
|
+
<AppFeaturesProvider features={[schema]}>
|
|
115
|
+
<UserRolesProvider roles={userRoles}>
|
|
116
|
+
<NavProvider
|
|
117
|
+
value={{
|
|
118
|
+
route: undefined,
|
|
119
|
+
navigate: navigateSpy,
|
|
120
|
+
replace: () => {},
|
|
121
|
+
hrefFor: () => "",
|
|
122
|
+
searchParams: {},
|
|
123
|
+
setSearchParams: () => {},
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
127
|
+
<KumikoScreen schema={schema} qn={qn} />
|
|
128
|
+
</PrimitivesProvider>
|
|
129
|
+
</NavProvider>
|
|
130
|
+
</UserRolesProvider>
|
|
131
|
+
</AppFeaturesProvider>
|
|
132
|
+
</DispatcherProvider>
|
|
133
|
+
</LocaleProvider>,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const entity: EntityDefinition = {
|
|
138
|
+
fields: {
|
|
139
|
+
name: { type: "text", maxLength: 50, required: false, searchable: false, sortable: false },
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
describe("entityList default edit row action", () => {
|
|
144
|
+
function schemaWith(
|
|
145
|
+
rowActions?: EntityListScreenDefinition["rowActions"],
|
|
146
|
+
includeEdit = true,
|
|
147
|
+
): FeatureSchema {
|
|
148
|
+
const listScreen: EntityListScreenDefinition = {
|
|
149
|
+
id: "rent-list",
|
|
150
|
+
type: "entityList",
|
|
151
|
+
entity: "rent",
|
|
152
|
+
columns: ["name"],
|
|
153
|
+
...(rowActions !== undefined && { rowActions }),
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
featureName: "app",
|
|
157
|
+
entities: { rent: entity },
|
|
158
|
+
screens: includeEdit ? [listScreen, editScreen("rent")] : [listScreen],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
test("adds a default edit action at the first position when an entityEdit screen exists", async () => {
|
|
163
|
+
captured = { rowActions: undefined, rowCount: 0 };
|
|
164
|
+
const navigateCalls: NavTarget[] = [];
|
|
165
|
+
renderScreen(schemaWith(undefined), "app:screen:rent-list", (t) => navigateCalls.push(t), []);
|
|
166
|
+
await waitFor(() => expect(captured.rowCount).toBe(1));
|
|
167
|
+
|
|
168
|
+
expect(captured.rowActions?.[0]?.id).toBe("edit");
|
|
169
|
+
expect(captured.rowActions?.[0]?.label).toBe("Edit");
|
|
170
|
+
|
|
171
|
+
await requireAction("edit").onTrigger({
|
|
172
|
+
id: "row-1",
|
|
173
|
+
values: { id: "row-1", name: "Alice" },
|
|
174
|
+
});
|
|
175
|
+
expect(navigateCalls).toEqual([{ screenId: "rent-edit", entityId: "row-1" }]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a declared rowAction with id 'edit' wins — no doubling", async () => {
|
|
179
|
+
captured = { rowActions: undefined, rowCount: 0 };
|
|
180
|
+
renderScreen(
|
|
181
|
+
schemaWith([{ kind: "navigate", id: "edit", label: "custom-edit", screen: "rent-edit" }]),
|
|
182
|
+
"app:screen:rent-list",
|
|
183
|
+
() => {},
|
|
184
|
+
[],
|
|
185
|
+
);
|
|
186
|
+
await waitFor(() => expect(captured.rowCount).toBe(1));
|
|
187
|
+
|
|
188
|
+
const editActions = captured.rowActions?.filter((a) => a.id === "edit") ?? [];
|
|
189
|
+
expect(editActions).toHaveLength(1);
|
|
190
|
+
// "custom-edit" has no translation registered, so translate() returns
|
|
191
|
+
// the raw key — proving this is the declared action, not the default
|
|
192
|
+
// (whose label is the translated "kumiko.actions.edit" → "Edit").
|
|
193
|
+
expect(editActions[0]?.label).toBe("custom-edit");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("no entityEdit screen for the entity → no default edit action", async () => {
|
|
197
|
+
captured = { rowActions: undefined, rowCount: 0 };
|
|
198
|
+
renderScreen(schemaWith(undefined, false), "app:screen:rent-list", () => {}, []);
|
|
199
|
+
await waitFor(() => expect(captured.rowCount).toBe(1));
|
|
200
|
+
|
|
201
|
+
expect(captured.rowActions?.some((a) => a.id === "edit") ?? false).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("access denied to the entityEdit screen → no default edit action", async () => {
|
|
205
|
+
captured = { rowActions: undefined, rowCount: 0 };
|
|
206
|
+
const listScreen: EntityListScreenDefinition = {
|
|
207
|
+
id: "rent-list",
|
|
208
|
+
type: "entityList",
|
|
209
|
+
entity: "rent",
|
|
210
|
+
columns: ["name"],
|
|
211
|
+
};
|
|
212
|
+
const schema: FeatureSchema = {
|
|
213
|
+
featureName: "app",
|
|
214
|
+
entities: { rent: entity },
|
|
215
|
+
screens: [listScreen, editScreen("rent", ["admin"])],
|
|
216
|
+
};
|
|
217
|
+
renderScreen(schema, "app:screen:rent-list", () => {}, []);
|
|
218
|
+
await waitFor(() => expect(captured.rowCount).toBe(1));
|
|
219
|
+
|
|
220
|
+
expect(captured.rowActions?.some((a) => a.id === "edit") ?? false).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("projectionList default edit row action", () => {
|
|
225
|
+
function schemaWith(
|
|
226
|
+
rowActions?: ProjectionListScreenDefinition["rowActions"],
|
|
227
|
+
includeEdit = true,
|
|
228
|
+
): FeatureSchema {
|
|
229
|
+
const listScreen: ProjectionListScreenDefinition = {
|
|
230
|
+
id: "rent-projection-list",
|
|
231
|
+
type: "projectionList",
|
|
232
|
+
query: "app:query:rent:list",
|
|
233
|
+
detailFor: "rent",
|
|
234
|
+
columns: [{ field: "name", label: "Name" }],
|
|
235
|
+
...(rowActions !== undefined && { rowActions }),
|
|
236
|
+
};
|
|
237
|
+
return {
|
|
238
|
+
featureName: "app",
|
|
239
|
+
entities: {},
|
|
240
|
+
screens: includeEdit ? [listScreen, editScreen("rent")] : [listScreen],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
test("adds a default edit action at the first position when detailFor resolves an entityEdit screen", async () => {
|
|
245
|
+
captured = { rowActions: undefined, rowCount: 0 };
|
|
246
|
+
const navigateCalls: NavTarget[] = [];
|
|
247
|
+
renderScreen(
|
|
248
|
+
schemaWith(undefined),
|
|
249
|
+
"app:screen:rent-projection-list",
|
|
250
|
+
(t) => navigateCalls.push(t),
|
|
251
|
+
[],
|
|
252
|
+
);
|
|
253
|
+
await waitFor(() => expect(captured.rowCount).toBe(1));
|
|
254
|
+
|
|
255
|
+
expect(captured.rowActions?.[0]?.id).toBe("edit");
|
|
256
|
+
expect(captured.rowActions?.[0]?.label).toBe("Edit");
|
|
257
|
+
|
|
258
|
+
await requireAction("edit").onTrigger({
|
|
259
|
+
id: "row-1",
|
|
260
|
+
values: { id: "row-1", name: "Alice" },
|
|
261
|
+
});
|
|
262
|
+
expect(navigateCalls).toEqual([{ screenId: "rent-edit", entityId: "row-1" }]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("a declared rowAction with id 'edit' wins — no doubling", async () => {
|
|
266
|
+
captured = { rowActions: undefined, rowCount: 0 };
|
|
267
|
+
renderScreen(
|
|
268
|
+
schemaWith([{ kind: "navigate", id: "edit", label: "custom-edit", screen: "rent-edit" }]),
|
|
269
|
+
"app:screen:rent-projection-list",
|
|
270
|
+
() => {},
|
|
271
|
+
[],
|
|
272
|
+
);
|
|
273
|
+
await waitFor(() => expect(captured.rowCount).toBe(1));
|
|
274
|
+
|
|
275
|
+
const editActions = captured.rowActions?.filter((a) => a.id === "edit") ?? [];
|
|
276
|
+
expect(editActions).toHaveLength(1);
|
|
277
|
+
expect(editActions[0]?.label).toBe("custom-edit");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("no detailFor → no default edit action", async () => {
|
|
281
|
+
captured = { rowActions: undefined, rowCount: 0 };
|
|
282
|
+
const listScreen: ProjectionListScreenDefinition = {
|
|
283
|
+
id: "rent-projection-list",
|
|
284
|
+
type: "projectionList",
|
|
285
|
+
query: "app:query:rent:list",
|
|
286
|
+
columns: [{ field: "name", label: "Name" }],
|
|
287
|
+
};
|
|
288
|
+
const schema: FeatureSchema = {
|
|
289
|
+
featureName: "app",
|
|
290
|
+
entities: {},
|
|
291
|
+
screens: [listScreen, editScreen("rent")],
|
|
292
|
+
};
|
|
293
|
+
renderScreen(schema, "app:screen:rent-projection-list", () => {}, []);
|
|
294
|
+
await waitFor(() => expect(captured.rowCount).toBe(1));
|
|
295
|
+
|
|
296
|
+
expect(captured.rowActions?.some((a) => a.id === "edit") ?? false).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("access denied to the entityEdit screen → no default edit action", async () => {
|
|
300
|
+
captured = { rowActions: undefined, rowCount: 0 };
|
|
301
|
+
renderScreen(
|
|
302
|
+
{
|
|
303
|
+
featureName: "app",
|
|
304
|
+
entities: {},
|
|
305
|
+
screens: [
|
|
306
|
+
{
|
|
307
|
+
id: "rent-projection-list",
|
|
308
|
+
type: "projectionList",
|
|
309
|
+
query: "app:query:rent:list",
|
|
310
|
+
detailFor: "rent",
|
|
311
|
+
columns: [{ field: "name", label: "Name" }],
|
|
312
|
+
},
|
|
313
|
+
editScreen("rent", ["admin"]),
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
"app:screen:rent-projection-list",
|
|
317
|
+
() => {},
|
|
318
|
+
[],
|
|
319
|
+
);
|
|
320
|
+
await waitFor(() => expect(captured.rowCount).toBe(1));
|
|
321
|
+
|
|
322
|
+
expect(captured.rowActions?.some((a) => a.id === "edit") ?? false).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -50,6 +50,9 @@ export function synthesizeActionFormScreen(
|
|
|
50
50
|
layout: screen.layout,
|
|
51
51
|
...(screen.description !== undefined && { description: screen.description }),
|
|
52
52
|
...(screen.access !== undefined && { access: screen.access }),
|
|
53
|
+
// Only ActionFormScreenDefinition carries slots — SecretMintScreenDefinition
|
|
54
|
+
// has none, so `in` is the narrowing (no cast).
|
|
55
|
+
...("slots" in screen && screen.slots !== undefined && { slots: screen.slots }),
|
|
53
56
|
};
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// PlatformComponent über dieselbe Registry auf und mountet die passende
|
|
5
5
|
// Component — die Bundled-Feature-/App-Component lädt + persistiert dann
|
|
6
6
|
// ihre eigenen Daten (z.B. custom-fields, oder ein eigenständiger Chart).
|
|
7
|
+
// The entityEdit footer slot (`screen.slots.footer`) mounts through it too.
|
|
7
8
|
//
|
|
8
9
|
// Mounting analog zu CustomScreensProvider — createKumikoApp im
|
|
9
10
|
// renderer-web sammelt alle clientFeatures.extensionSectionComponents und
|
|
@@ -66,6 +67,14 @@ export type ExtensionSectionProps = {
|
|
|
66
67
|
* `snapshot.errors` on the field instead of a collective message.
|
|
67
68
|
* Undefined outside entityEdit sections. */
|
|
68
69
|
readonly validate?: () => boolean;
|
|
70
|
+
/** Whether the host form has unsaved changes. Only set in the entityEdit
|
|
71
|
+
* footer slot (`screen.slots.footer`); undefined in all other mounts. */
|
|
72
|
+
readonly hasUnsavedChanges?: boolean;
|
|
73
|
+
/** Current wizard step, only set for `layout.mode === "wizard"` forms.
|
|
74
|
+
* Only set in the entityEdit footer slot (`screen.slots.footer`);
|
|
75
|
+
* undefined in all other mounts, and undefined there too for a
|
|
76
|
+
* non-wizard form. */
|
|
77
|
+
readonly wizardStep?: { readonly index: number; readonly isLast: boolean };
|
|
69
78
|
};
|
|
70
79
|
|
|
71
80
|
export type ExtensionSectionComponent = ComponentType<ExtensionSectionProps>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ConfigCascade } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import type {
|
|
3
|
+
ActionFormRedirect,
|
|
3
4
|
ActionFormScreenDefinition,
|
|
4
5
|
ConfigEditScreenDefinition,
|
|
5
6
|
DashboardScreenDefinition,
|
|
@@ -73,6 +74,7 @@ import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projec
|
|
|
73
74
|
import { lastSegment, toKebab } from "./qn";
|
|
74
75
|
import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
|
|
75
76
|
import {
|
|
77
|
+
buildDefaultEditRowAction,
|
|
76
78
|
buildProjectionRowActions,
|
|
77
79
|
evalRowExtractor,
|
|
78
80
|
isWriteHandlerRowAction,
|
|
@@ -81,7 +83,7 @@ import {
|
|
|
81
83
|
runProjectionRowNavigate,
|
|
82
84
|
stringifyNavParams,
|
|
83
85
|
} from "./row-actions";
|
|
84
|
-
import { screenAccessAllows } from "./screen-access";
|
|
86
|
+
import { findEditScreenFor, screenAccessAllows } from "./screen-access";
|
|
85
87
|
import { SecretMintBody } from "./secret-mint-body";
|
|
86
88
|
import { SecretsEditBody } from "./secrets-edit-body";
|
|
87
89
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
@@ -685,6 +687,7 @@ function EntityEditCreateBody({
|
|
|
685
687
|
readonly onSaved?: () => void;
|
|
686
688
|
}): ReactNode {
|
|
687
689
|
const nav = useNav();
|
|
690
|
+
const appFeatures = useAppFeatures();
|
|
688
691
|
const initial = useMemo(
|
|
689
692
|
() =>
|
|
690
693
|
mergeSearchParamsIntoInitial(
|
|
@@ -702,17 +705,31 @@ function EntityEditCreateBody({
|
|
|
702
705
|
(result: SubmitResult<unknown>) => {
|
|
703
706
|
if (!result.isSuccess) return;
|
|
704
707
|
if (screen.redirect !== undefined) {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
708
|
+
// String form unchanged: always carries the newly created
|
|
709
|
+
// record's own id, regardless of the target screen's type. The
|
|
710
|
+
// object form resolves like actionForm's — a child record's own id
|
|
711
|
+
// is useless for a parent-detail redirect.
|
|
712
|
+
if (typeof screen.redirect === "string") {
|
|
713
|
+
const entityId = extractCreatedId(result.data);
|
|
714
|
+
nav.navigate({
|
|
715
|
+
screenId: lastSegment(screen.redirect),
|
|
716
|
+
...(entityId !== undefined && { entityId }),
|
|
717
|
+
});
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const { screenId, entityId } = resolveRedirectTarget(
|
|
721
|
+
screen.redirect,
|
|
722
|
+
result.data,
|
|
723
|
+
schema,
|
|
724
|
+
appFeatures,
|
|
725
|
+
);
|
|
726
|
+
nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
|
|
710
727
|
return;
|
|
711
728
|
}
|
|
712
729
|
navigateToList();
|
|
713
730
|
onSaved?.();
|
|
714
731
|
},
|
|
715
|
-
[nav, screen.redirect, navigateToList, onSaved],
|
|
732
|
+
[nav, screen.redirect, schema, appFeatures, navigateToList, onSaved],
|
|
716
733
|
);
|
|
717
734
|
// Deliberately no `actions` prop here: `screen.actions` targets an
|
|
718
735
|
// EXISTING record (publish/archive/duplicate and friends), which the
|
|
@@ -885,6 +902,7 @@ function EntityEditUpdateForm({
|
|
|
885
902
|
);
|
|
886
903
|
|
|
887
904
|
const nav = useNav();
|
|
905
|
+
const appFeatures = useAppFeatures();
|
|
888
906
|
const dispatcher = useDispatcher();
|
|
889
907
|
const t = useTranslation();
|
|
890
908
|
const effectiveTranslate = translate ?? t;
|
|
@@ -1007,13 +1025,30 @@ function EntityEditUpdateForm({
|
|
|
1007
1025
|
(result: SubmitResult<unknown>) => {
|
|
1008
1026
|
if (!result.isSuccess) return;
|
|
1009
1027
|
if (screen.redirect !== undefined) {
|
|
1010
|
-
|
|
1028
|
+
// String form unchanged: navigates without an entityId, same as
|
|
1029
|
+
// before the object form existed. The object form resolves like
|
|
1030
|
+
// actionForm's — the update handler's success payload usually
|
|
1031
|
+
// reports only this record's own id (event-store-executor-write.ts),
|
|
1032
|
+
// so a parent FK named by `idFrom` falls back to the already-loaded
|
|
1033
|
+
// `record`.
|
|
1034
|
+
if (typeof screen.redirect === "string") {
|
|
1035
|
+
nav.navigate({ screenId: lastSegment(screen.redirect) });
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const { screenId, entityId } = resolveRedirectTarget(
|
|
1039
|
+
screen.redirect,
|
|
1040
|
+
result.data,
|
|
1041
|
+
schema,
|
|
1042
|
+
appFeatures,
|
|
1043
|
+
record,
|
|
1044
|
+
);
|
|
1045
|
+
nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
|
|
1011
1046
|
return;
|
|
1012
1047
|
}
|
|
1013
1048
|
navigateToList();
|
|
1014
1049
|
onSaved?.();
|
|
1015
1050
|
},
|
|
1016
|
-
[nav, screen.redirect, navigateToList, onSaved],
|
|
1051
|
+
[nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved],
|
|
1017
1052
|
);
|
|
1018
1053
|
const handleDelete = useCallback(async () => {
|
|
1019
1054
|
const res = await dispatcher.write(deleteCommand, { id: entityId });
|
|
@@ -1449,8 +1484,13 @@ function EntityListBody({
|
|
|
1449
1484
|
const queryType = entityQueryCommand(featureName, screen.entity, "list");
|
|
1450
1485
|
const nav = useNav();
|
|
1451
1486
|
const userRoles = useUserRoles();
|
|
1487
|
+
const appFeatures = useAppFeatures();
|
|
1452
1488
|
const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
|
|
1453
1489
|
useDrawerAction(schema);
|
|
1490
|
+
const defaultEditScreen = useMemo(
|
|
1491
|
+
() => findEditScreenFor(screen.entity, appFeatures, userRoles),
|
|
1492
|
+
[appFeatures, screen.entity, userRoles],
|
|
1493
|
+
);
|
|
1454
1494
|
|
|
1455
1495
|
// URL-State: sort/dir/q/page leben unter dem screen.id-Namespace
|
|
1456
1496
|
// (`/orders?orders.sort=createdAt&orders.dir=desc&orders.q=acme`),
|
|
@@ -1672,8 +1712,16 @@ function EntityListBody({
|
|
|
1672
1712
|
);
|
|
1673
1713
|
|
|
1674
1714
|
const rowActions = useMemo(() => {
|
|
1675
|
-
|
|
1676
|
-
|
|
1715
|
+
const declared = screen.rowActions ?? [];
|
|
1716
|
+
// Prepended unless a declared rowAction already has id "edit" — declared wins.
|
|
1717
|
+
const declaredHasEdit = declared.some((a) => a.id === "edit");
|
|
1718
|
+
const defaultEditRowAction = declaredHasEdit
|
|
1719
|
+
? undefined
|
|
1720
|
+
: buildDefaultEditRowAction(defaultEditScreen);
|
|
1721
|
+
const effectiveActions: readonly RowAction[] =
|
|
1722
|
+
defaultEditRowAction !== undefined ? [defaultEditRowAction, ...declared] : declared;
|
|
1723
|
+
if (effectiveActions.length === 0) return undefined;
|
|
1724
|
+
return effectiveActions
|
|
1677
1725
|
.map((action: RowAction): DataTableRowAction | null => {
|
|
1678
1726
|
// navigate-Variante braucht keinen Dispatcher; nav ist
|
|
1679
1727
|
// immer da (Provider von createKumikoApp).
|
|
@@ -1757,6 +1805,7 @@ function EntityListBody({
|
|
|
1757
1805
|
.filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
|
|
1758
1806
|
}, [
|
|
1759
1807
|
screen.rowActions,
|
|
1808
|
+
defaultEditScreen,
|
|
1760
1809
|
effectiveTranslate,
|
|
1761
1810
|
dispatcher,
|
|
1762
1811
|
runNavigate,
|
|
@@ -1967,8 +2016,14 @@ function ProjectionListBody({
|
|
|
1967
2016
|
const dispatcher = useOptionalDispatcher();
|
|
1968
2017
|
const effectiveTranslate = translate ?? t;
|
|
1969
2018
|
const userRoles = useUserRoles();
|
|
2019
|
+
const appFeatures = useAppFeatures();
|
|
1970
2020
|
const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
|
|
1971
2021
|
useDrawerAction(schema);
|
|
2022
|
+
const defaultEditScreen = useMemo(() => {
|
|
2023
|
+
const detailFor = screen.detailFor;
|
|
2024
|
+
if (detailFor === undefined) return undefined;
|
|
2025
|
+
return findEditScreenFor(detailFor, appFeatures, userRoles);
|
|
2026
|
+
}, [appFeatures, screen.detailFor, userRoles]);
|
|
1972
2027
|
|
|
1973
2028
|
// searchable/sortable/paginated are derived at buildAppSchema time from the
|
|
1974
2029
|
// query handler's Zod schema (fw#2165) — not authored on the screen.
|
|
@@ -2060,6 +2115,11 @@ function ProjectionListBody({
|
|
|
2060
2115
|
[nav],
|
|
2061
2116
|
);
|
|
2062
2117
|
|
|
2118
|
+
const defaultEditRowAction = useMemo(
|
|
2119
|
+
() => buildDefaultEditRowAction(defaultEditScreen),
|
|
2120
|
+
[defaultEditScreen],
|
|
2121
|
+
);
|
|
2122
|
+
|
|
2063
2123
|
const rowActions = useMemo(
|
|
2064
2124
|
() =>
|
|
2065
2125
|
buildProjectionRowActions({
|
|
@@ -2069,8 +2129,17 @@ function ProjectionListBody({
|
|
|
2069
2129
|
nav,
|
|
2070
2130
|
refetch: rowsQuery.refetch,
|
|
2071
2131
|
openDrawer,
|
|
2132
|
+
defaultEditRowAction,
|
|
2072
2133
|
}),
|
|
2073
|
-
[
|
|
2134
|
+
[
|
|
2135
|
+
screen.rowActions,
|
|
2136
|
+
effectiveTranslate,
|
|
2137
|
+
dispatcher,
|
|
2138
|
+
nav,
|
|
2139
|
+
rowsQuery.refetch,
|
|
2140
|
+
openDrawer,
|
|
2141
|
+
defaultEditRowAction,
|
|
2142
|
+
],
|
|
2074
2143
|
);
|
|
2075
2144
|
|
|
2076
2145
|
const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
|
|
@@ -2242,6 +2311,13 @@ function runMetricNavigate(
|
|
|
2242
2311
|
navigate: MetricNavigate,
|
|
2243
2312
|
record: Readonly<Record<string, unknown>>,
|
|
2244
2313
|
): void {
|
|
2314
|
+
// tab alone (no screen/entity) stays on the current record and just
|
|
2315
|
+
// activates that tab — no route change, so runProjectionRowNavigate
|
|
2316
|
+
// (which always navigates) doesn't apply here.
|
|
2317
|
+
if (navigate.screen === undefined && navigate.entity === undefined) {
|
|
2318
|
+
if (navigate.tab !== undefined) nav.setSearchParams({ tab: navigate.tab });
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2245
2321
|
const base = { kind: "navigate" as const, id: "metric-navigate", label: "" };
|
|
2246
2322
|
const action: RowActionNavigate | undefined =
|
|
2247
2323
|
navigate.entity !== undefined
|
|
@@ -2261,6 +2337,7 @@ function runMetricNavigate(
|
|
|
2261
2337
|
: undefined;
|
|
2262
2338
|
if (action === undefined) return;
|
|
2263
2339
|
runProjectionRowNavigate(nav, action, { id: "", values: record });
|
|
2340
|
+
if (navigate.tab !== undefined) nav.setSearchParams({ tab: navigate.tab });
|
|
2264
2341
|
}
|
|
2265
2342
|
|
|
2266
2343
|
// Absolute http(s) check for RecordHeaderSpec.subtitleHref — deliberately
|
|
@@ -2496,19 +2573,7 @@ function ProjectionDetailBody({
|
|
|
2496
2573
|
const editScreen = useMemo(() => {
|
|
2497
2574
|
const detailFor = screen.detailFor;
|
|
2498
2575
|
if (detailFor === undefined) return undefined;
|
|
2499
|
-
|
|
2500
|
-
// Access-check is part of the find predicate, not a filter applied
|
|
2501
|
-
// after the first match — two entityEdit screens for the same entity
|
|
2502
|
-
// where the first is role-gated must not hide an accessible second one.
|
|
2503
|
-
const match = feature.screens.find(
|
|
2504
|
-
(s): s is EntityEditScreenDefinition =>
|
|
2505
|
-
s.type === "entityEdit" &&
|
|
2506
|
-
s.entity === detailFor &&
|
|
2507
|
-
screenAccessAllows(s.access, userRoles),
|
|
2508
|
-
);
|
|
2509
|
-
if (match !== undefined) return match;
|
|
2510
|
-
}
|
|
2511
|
-
return undefined;
|
|
2576
|
+
return findEditScreenFor(detailFor, appFeatures, userRoles);
|
|
2512
2577
|
}, [appFeatures, screen.detailFor, userRoles]);
|
|
2513
2578
|
const defaultEditAction = useMemo((): RenderEditAction | undefined => {
|
|
2514
2579
|
if (editScreen === undefined) return undefined;
|
|
@@ -2740,39 +2805,42 @@ function ProjectionDetailBody({
|
|
|
2740
2805
|
<Card>
|
|
2741
2806
|
{header !== undefined && (
|
|
2742
2807
|
<>
|
|
2743
|
-
|
|
2744
|
-
{String(record[header.title] ?? "")}
|
|
2745
|
-
</Heading>
|
|
2746
|
-
{(header.subtitle !== undefined || header.status !== undefined) && (
|
|
2808
|
+
{header.status !== undefined ? (
|
|
2747
2809
|
<Grid columns="auto">
|
|
2748
|
-
|
|
2749
|
-
(
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
{header.status !== undefined &&
|
|
2763
|
-
(StatusBadge !== undefined ? (
|
|
2764
|
-
<StatusBadge
|
|
2765
|
-
value={String(record[header.status] ?? "")}
|
|
2766
|
-
tone={statusToneForValue(String(record[header.status] ?? ""))}
|
|
2767
|
-
testId="kumiko-screen-projection-detail-status"
|
|
2768
|
-
/>
|
|
2769
|
-
) : (
|
|
2770
|
-
<Text testId="kumiko-screen-projection-detail-status">
|
|
2771
|
-
{String(record[header.status] ?? "")}
|
|
2772
|
-
</Text>
|
|
2773
|
-
))}
|
|
2810
|
+
<Heading variant="page" testId="kumiko-screen-projection-detail-title">
|
|
2811
|
+
{String(record[header.title] ?? "")}
|
|
2812
|
+
</Heading>
|
|
2813
|
+
{StatusBadge !== undefined ? (
|
|
2814
|
+
<StatusBadge
|
|
2815
|
+
value={String(record[header.status] ?? "")}
|
|
2816
|
+
tone={statusToneForValue(String(record[header.status] ?? ""))}
|
|
2817
|
+
testId="kumiko-screen-projection-detail-status"
|
|
2818
|
+
/>
|
|
2819
|
+
) : (
|
|
2820
|
+
<Text testId="kumiko-screen-projection-detail-status">
|
|
2821
|
+
{String(record[header.status] ?? "")}
|
|
2822
|
+
</Text>
|
|
2823
|
+
)}
|
|
2774
2824
|
</Grid>
|
|
2825
|
+
) : (
|
|
2826
|
+
<Heading variant="page" testId="kumiko-screen-projection-detail-title">
|
|
2827
|
+
{String(record[header.title] ?? "")}
|
|
2828
|
+
</Heading>
|
|
2775
2829
|
)}
|
|
2830
|
+
{header.subtitle !== undefined &&
|
|
2831
|
+
(subtitleHref !== undefined ? (
|
|
2832
|
+
<Link
|
|
2833
|
+
href={subtitleHref}
|
|
2834
|
+
target="_blank"
|
|
2835
|
+
testId="kumiko-screen-projection-detail-subtitle"
|
|
2836
|
+
>
|
|
2837
|
+
{String(record[header.subtitle] ?? "")}
|
|
2838
|
+
</Link>
|
|
2839
|
+
) : (
|
|
2840
|
+
<Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
|
|
2841
|
+
{String(record[header.subtitle] ?? "")}
|
|
2842
|
+
</Text>
|
|
2843
|
+
))}
|
|
2776
2844
|
</>
|
|
2777
2845
|
)}
|
|
2778
2846
|
{hasMetrics && (
|
|
@@ -2881,6 +2949,45 @@ function redirectScreenTarget(
|
|
|
2881
2949
|
return typeof redirect === "string" ? redirect : redirect.screen;
|
|
2882
2950
|
}
|
|
2883
2951
|
|
|
2952
|
+
// Resolves an object-form redirect to a nav target — shared by
|
|
2953
|
+
// actionForm and entityEdit (create + update) so the id
|
|
2954
|
+
// carries over identically regardless of which screen type triggered it.
|
|
2955
|
+
// The target screen may live in another feature (cross-feature QN), so
|
|
2956
|
+
// resolution checks this schema first, then every mounted feature — same
|
|
2957
|
+
// fallback order as the create-dialog's reference-field screen lookup.
|
|
2958
|
+
// `carriesId` gates entityId on the TARGET screen type: a redirect to a
|
|
2959
|
+
// list screen never gets an id attached, matching actionForm's original
|
|
2960
|
+
// behavior. The id itself prefers the write-handler's success payload
|
|
2961
|
+
// (`submittedData`) and falls back to `fallbackRecord` — the entityEdit
|
|
2962
|
+
// update path's already-loaded record, which has fields (e.g. a parent FK)
|
|
2963
|
+
// the CRUD write executor's success payload doesn't flatly expose.
|
|
2964
|
+
function resolveRedirectTarget(
|
|
2965
|
+
redirect: string | ActionFormRedirect,
|
|
2966
|
+
submittedData: unknown,
|
|
2967
|
+
schema: FeatureSchema,
|
|
2968
|
+
appFeatures: readonly FeatureSchema[],
|
|
2969
|
+
fallbackRecord?: Readonly<Record<string, unknown>>,
|
|
2970
|
+
): { readonly screenId: string; readonly entityId: string | undefined } {
|
|
2971
|
+
const redirectScreen = redirectScreenTarget(redirect);
|
|
2972
|
+
const idField = typeof redirect === "string" ? "id" : redirect.idFrom;
|
|
2973
|
+
const targetId = lastSegment(redirectScreen);
|
|
2974
|
+
const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
|
|
2975
|
+
const target =
|
|
2976
|
+
schema.screens.find((s) => lastSegment(s.id) === targetId) ??
|
|
2977
|
+
(targetFeatureName !== undefined
|
|
2978
|
+
? appFeatures
|
|
2979
|
+
.find((f) => f.featureName === targetFeatureName)
|
|
2980
|
+
?.screens.find((s) => lastSegment(s.id) === targetId)
|
|
2981
|
+
: appFeatures.flatMap((f) => f.screens).find((s) => lastSegment(s.id) === targetId));
|
|
2982
|
+
const carriesId =
|
|
2983
|
+
target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail");
|
|
2984
|
+
if (!carriesId) return { screenId: targetId, entityId: undefined };
|
|
2985
|
+
const entityId =
|
|
2986
|
+
extractIdField(submittedData, idField) ??
|
|
2987
|
+
(fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
|
|
2988
|
+
return { screenId: targetId, entityId };
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2884
2991
|
// Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
|
|
2885
2992
|
// RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
|
|
2886
2993
|
// Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
|
|
@@ -2939,39 +3046,16 @@ function ActionFormBody({
|
|
|
2939
3046
|
// Author entscheidet bewusst ob "stay on form" (default) oder
|
|
2940
3047
|
// "back to list" (typisch bei Create-style Aktionen).
|
|
2941
3048
|
if (screen.redirect !== undefined) {
|
|
2942
|
-
const
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
// as ProjectionDetailBody's cross-feature editScreen lookup above.
|
|
2950
|
-
// A screen id is only unique WITHIN a feature, so once the QN names
|
|
2951
|
-
// a feature, the fallback must look inside THAT feature, not just
|
|
2952
|
-
// take the first short-id match across every mounted feature — two
|
|
2953
|
-
// features can easily share an id like "list" or "edit".
|
|
2954
|
-
const target =
|
|
2955
|
-
schema.screens.find((s) => lastSegment(s.id) === targetId) ??
|
|
2956
|
-
(targetFeatureName !== undefined
|
|
2957
|
-
? appFeatures
|
|
2958
|
-
.find((f) => f.featureName === targetFeatureName)
|
|
2959
|
-
?.screens.find((s) => lastSegment(s.id) === targetId)
|
|
2960
|
-
: // Bare short-id redirect (no feature prefix) names no feature to
|
|
2961
|
-
// pick by — fall back to the pre-fw#2485 best-effort match by
|
|
2962
|
-
// short id across all mounted features.
|
|
2963
|
-
appFeatures.flatMap((f) => f.screens).find((s) => lastSegment(s.id) === targetId));
|
|
2964
|
-
const entityId = extractIdField(result.data, idField);
|
|
2965
|
-
const carriesId =
|
|
2966
|
-
target !== undefined &&
|
|
2967
|
-
(target.type === "entityEdit" || target.type === "projectionDetail");
|
|
2968
|
-
nav.navigate({
|
|
2969
|
-
screenId: targetId,
|
|
2970
|
-
...(carriesId && entityId !== undefined && { entityId }),
|
|
2971
|
-
});
|
|
3049
|
+
const { screenId, entityId } = resolveRedirectTarget(
|
|
3050
|
+
screen.redirect,
|
|
3051
|
+
result.data,
|
|
3052
|
+
schema,
|
|
3053
|
+
appFeatures,
|
|
3054
|
+
);
|
|
3055
|
+
nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
|
|
2972
3056
|
}
|
|
2973
3057
|
},
|
|
2974
|
-
[nav, screen.redirect, onSuccess, schema
|
|
3058
|
+
[nav, screen.redirect, onSuccess, schema, appFeatures],
|
|
2975
3059
|
);
|
|
2976
3060
|
// Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
|
|
2977
3061
|
// sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
|
package/src/app/row-actions.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
EntityEditScreenDefinition,
|
|
2
3
|
IconKey,
|
|
3
4
|
RowAction,
|
|
4
5
|
RowActionDrawer,
|
|
@@ -10,8 +11,25 @@ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
|
10
11
|
import type { Dispatcher, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
|
|
11
12
|
import type { DataTableRowAction } from "../primitives";
|
|
12
13
|
import type { NavApi } from "./nav";
|
|
14
|
+
import { lastSegment } from "./qn";
|
|
13
15
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
14
16
|
|
|
17
|
+
// entityId is explicit: the edit screen may live in another feature than
|
|
18
|
+
// the row source, where the same-feature fallback would miss it.
|
|
19
|
+
export function buildDefaultEditRowAction(
|
|
20
|
+
editScreen: EntityEditScreenDefinition | undefined,
|
|
21
|
+
idColumn = "id",
|
|
22
|
+
): RowActionNavigate | undefined {
|
|
23
|
+
if (editScreen === undefined) return undefined;
|
|
24
|
+
return {
|
|
25
|
+
kind: "navigate",
|
|
26
|
+
id: "edit",
|
|
27
|
+
label: "kumiko.actions.edit",
|
|
28
|
+
screen: lastSegment(editScreen.id),
|
|
29
|
+
entityId: idColumn,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
export function evalRowExtractor(
|
|
16
34
|
extractor: RowFieldExtractor,
|
|
17
35
|
row: Record<string, unknown>,
|
|
@@ -206,6 +224,51 @@ function buildDrawerRowAction(
|
|
|
206
224
|
};
|
|
207
225
|
}
|
|
208
226
|
|
|
227
|
+
// Prepends defaultEditRowAction unless a declared action already claims id
|
|
228
|
+
// "edit" — declared wins.
|
|
229
|
+
function mergeDefaultEditAction(
|
|
230
|
+
rowActions: readonly RowAction[] | undefined,
|
|
231
|
+
defaultEditRowAction: RowActionNavigate | undefined,
|
|
232
|
+
): readonly RowAction[] {
|
|
233
|
+
const declaredHasEdit = rowActions?.some((a) => a.id === "edit") === true;
|
|
234
|
+
return defaultEditRowAction !== undefined && !declaredHasEdit
|
|
235
|
+
? [defaultEditRowAction, ...(rowActions ?? [])]
|
|
236
|
+
: (rowActions ?? []);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function buildWriteHandlerRowAction(
|
|
240
|
+
action: RowActionWriteHandler,
|
|
241
|
+
translate: Translate,
|
|
242
|
+
refetch: () => Promise<unknown>,
|
|
243
|
+
dispatcher: Dispatcher,
|
|
244
|
+
): DataTableRowAction {
|
|
245
|
+
const writeVisible = action.visible;
|
|
246
|
+
return {
|
|
247
|
+
id: action.id,
|
|
248
|
+
label: translate(action.label),
|
|
249
|
+
...(action.style !== undefined && { style: action.style }),
|
|
250
|
+
icon: resolveActionIcon(action.id, action.icon),
|
|
251
|
+
...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
|
|
252
|
+
...(action.confirmLabel !== undefined && {
|
|
253
|
+
confirmLabel: translate(action.confirmLabel),
|
|
254
|
+
}),
|
|
255
|
+
onTrigger: async (row: ListRowViewModel) => {
|
|
256
|
+
const payload =
|
|
257
|
+
action.payload !== undefined
|
|
258
|
+
? evalRowExtractor(action.payload, row.values)
|
|
259
|
+
: { id: row.values["id"] };
|
|
260
|
+
const result = await dispatcher.write(action.handler, payload);
|
|
261
|
+
if (!result.isSuccess) {
|
|
262
|
+
throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
|
|
263
|
+
}
|
|
264
|
+
await refetchAfterWrite(refetch);
|
|
265
|
+
},
|
|
266
|
+
...(writeVisible !== undefined && {
|
|
267
|
+
isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
|
|
268
|
+
}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
209
272
|
// Builds the DataTable-ready row-action set for a query-driven row source
|
|
210
273
|
// (projectionList, relatedList) — navigate dispatches through
|
|
211
274
|
// runProjectionRowNavigate, writeHandler dispatches through the shared
|
|
@@ -223,11 +286,15 @@ export function buildProjectionRowActions(options: {
|
|
|
223
286
|
* without onOpenDrawer) drop drawer actions and get a dev warning —
|
|
224
287
|
* mirrors the `dispatcher === undefined` skip below for writeHandler. */
|
|
225
288
|
readonly openDrawer?: OpenDrawer;
|
|
289
|
+
/** Prepended unless a declared action already has id "edit" — declared wins. */
|
|
290
|
+
readonly defaultEditRowAction?: RowActionNavigate;
|
|
226
291
|
}): readonly DataTableRowAction[] | undefined {
|
|
227
|
-
const { rowActions, translate, dispatcher, nav, refetch, openDrawer } =
|
|
228
|
-
|
|
292
|
+
const { rowActions, translate, dispatcher, nav, refetch, openDrawer, defaultEditRowAction } =
|
|
293
|
+
options;
|
|
294
|
+
const effectiveActions = mergeDefaultEditAction(rowActions, defaultEditRowAction);
|
|
295
|
+
if (effectiveActions.length === 0) return undefined;
|
|
229
296
|
const out: DataTableRowAction[] = [];
|
|
230
|
-
for (const action of
|
|
297
|
+
for (const action of effectiveActions) {
|
|
231
298
|
if (action.kind === "navigate") {
|
|
232
299
|
out.push(buildNavigateRowAction(action, translate, nav));
|
|
233
300
|
continue;
|
|
@@ -243,32 +310,7 @@ export function buildProjectionRowActions(options: {
|
|
|
243
310
|
// writeHandler (default-kind) — a swallowed failure result must become a
|
|
244
311
|
// thrown error (fw prod-bug 2026-06-07), same as every other write path.
|
|
245
312
|
if (dispatcher === undefined) continue;
|
|
246
|
-
|
|
247
|
-
const writeVisible = writeAction.visible;
|
|
248
|
-
out.push({
|
|
249
|
-
id: writeAction.id,
|
|
250
|
-
label: translate(writeAction.label),
|
|
251
|
-
...(writeAction.style !== undefined && { style: writeAction.style }),
|
|
252
|
-
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
253
|
-
...(writeAction.confirm !== undefined && { confirm: translate(writeAction.confirm) }),
|
|
254
|
-
...(writeAction.confirmLabel !== undefined && {
|
|
255
|
-
confirmLabel: translate(writeAction.confirmLabel),
|
|
256
|
-
}),
|
|
257
|
-
onTrigger: async (row: ListRowViewModel) => {
|
|
258
|
-
const payload =
|
|
259
|
-
writeAction.payload !== undefined
|
|
260
|
-
? evalRowExtractor(writeAction.payload, row.values)
|
|
261
|
-
: { id: row.values["id"] };
|
|
262
|
-
const result = await dispatcher.write(writeAction.handler, payload);
|
|
263
|
-
if (!result.isSuccess) {
|
|
264
|
-
throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
|
|
265
|
-
}
|
|
266
|
-
await refetchAfterWrite(refetch);
|
|
267
|
-
},
|
|
268
|
-
...(writeVisible !== undefined && {
|
|
269
|
-
isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
|
|
270
|
-
}),
|
|
271
|
-
});
|
|
313
|
+
out.push(buildWriteHandlerRowAction(action, translate, refetch, dispatcher));
|
|
272
314
|
}
|
|
273
315
|
return out.length > 0 ? out : undefined;
|
|
274
316
|
}
|
package/src/app/screen-access.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AccessRule,
|
|
3
|
+
EntityEditScreenDefinition,
|
|
4
|
+
FeatureSchema,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
6
|
|
|
3
7
|
// Minimal role-gate for the screen-render path (#1203 — nav filtering via
|
|
4
8
|
// filterByAccess in workspace-shell.tsx hid role-gated screens from the
|
|
@@ -17,3 +21,20 @@ export function screenAccessAllows(
|
|
|
17
21
|
if (userRoles === undefined) return false;
|
|
18
22
|
return access.roles.some((role) => userRoles.includes(role));
|
|
19
23
|
}
|
|
24
|
+
|
|
25
|
+
// Searches all mounted features, and access is part of the predicate so a
|
|
26
|
+
// role-gated first match can't hide an accessible second one.
|
|
27
|
+
export function findEditScreenFor(
|
|
28
|
+
entity: string,
|
|
29
|
+
appFeatures: readonly FeatureSchema[],
|
|
30
|
+
userRoles: readonly string[] | undefined,
|
|
31
|
+
): EntityEditScreenDefinition | undefined {
|
|
32
|
+
for (const feature of appFeatures) {
|
|
33
|
+
const match = feature.screens.find(
|
|
34
|
+
(s): s is EntityEditScreenDefinition =>
|
|
35
|
+
s.type === "entityEdit" && s.entity === entity && screenAccessAllows(s.access, userRoles),
|
|
36
|
+
);
|
|
37
|
+
if (match !== undefined) return match;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
-
import type { RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { EntityEditScreenDefinition, RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import { fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
|
|
5
5
|
import type { ComponentType, ReactNode } from "react";
|
|
6
|
+
import { AppFeaturesProvider } from "../../app/app-features-context";
|
|
7
|
+
import type { FeatureSchema } from "../../app/feature-schema";
|
|
6
8
|
import { type NavApi, NavProvider } from "../../app/nav";
|
|
7
9
|
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
10
|
+
import { UserRolesProvider } from "../../context/user-roles-context";
|
|
8
11
|
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
9
12
|
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
10
13
|
import {
|
|
@@ -938,3 +941,118 @@ describe("RelatedListSection — search + facets (fw#2740)", () => {
|
|
|
938
941
|
]);
|
|
939
942
|
});
|
|
940
943
|
});
|
|
944
|
+
|
|
945
|
+
// Same findEditScreenFor/buildDefaultEditRowAction resolution as
|
|
946
|
+
// entityList/projectionList, driven by rowClick.entity and rowClick.idColumn.
|
|
947
|
+
describe("RelatedListSection — default edit row action", () => {
|
|
948
|
+
function editScreen(entity: string, roles?: readonly string[]): EntityEditScreenDefinition {
|
|
949
|
+
return {
|
|
950
|
+
id: "lease:screen:item-edit",
|
|
951
|
+
type: "entityEdit",
|
|
952
|
+
entity,
|
|
953
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
954
|
+
...(roles !== undefined && { access: { roles } }),
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function renderWithFeatures(
|
|
959
|
+
section: EditRelatedListSectionViewModel,
|
|
960
|
+
dispatcher: Dispatcher,
|
|
961
|
+
features: readonly FeatureSchema[],
|
|
962
|
+
userRoles: readonly string[] = [],
|
|
963
|
+
nav: NavApi = stubNav().nav,
|
|
964
|
+
) {
|
|
965
|
+
return render(
|
|
966
|
+
<LocaleProvider
|
|
967
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
968
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
969
|
+
>
|
|
970
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
971
|
+
<AppFeaturesProvider features={features}>
|
|
972
|
+
<UserRolesProvider roles={userRoles}>
|
|
973
|
+
<PrimitivesProvider value={testPrimitives()}>
|
|
974
|
+
<NavProvider value={nav}>
|
|
975
|
+
<RelatedListSection section={section} parentId="order-1" featureName="orders" />
|
|
976
|
+
</NavProvider>
|
|
977
|
+
</PrimitivesProvider>
|
|
978
|
+
</UserRolesProvider>
|
|
979
|
+
</AppFeaturesProvider>
|
|
980
|
+
</DispatcherProvider>
|
|
981
|
+
</LocaleProvider>,
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const sectionWithRowClick: EditRelatedListSectionViewModel = {
|
|
986
|
+
kind: "relatedList",
|
|
987
|
+
title: "Positions",
|
|
988
|
+
query: "lease:query:items:list",
|
|
989
|
+
columns: [{ field: "name" }],
|
|
990
|
+
rowClick: { entity: "item" },
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
test("adds a default edit action at the first position when rowClick.entity resolves an entityEdit screen", async () => {
|
|
994
|
+
const { dispatcher } = stubDispatcher();
|
|
995
|
+
const { nav, navigations } = stubNav();
|
|
996
|
+
const schema: FeatureSchema = {
|
|
997
|
+
featureName: "lease",
|
|
998
|
+
entities: {},
|
|
999
|
+
screens: [editScreen("item")],
|
|
1000
|
+
};
|
|
1001
|
+
renderWithFeatures(sectionWithRowClick, dispatcher, [schema], [], nav);
|
|
1002
|
+
|
|
1003
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
1004
|
+
const editButton = rtlScreen.getByTestId("action-edit-r1");
|
|
1005
|
+
expect(editButton.textContent).toBe("Edit");
|
|
1006
|
+
|
|
1007
|
+
fireEvent.click(editButton);
|
|
1008
|
+
await waitFor(() => expect(navigations).toHaveLength(1));
|
|
1009
|
+
expect(navigations[0]).toEqual({ screenId: "item-edit", entityId: "r1" });
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test("a declared rowAction with id 'edit' wins — no doubling", async () => {
|
|
1013
|
+
const { dispatcher } = stubDispatcher();
|
|
1014
|
+
const schema: FeatureSchema = {
|
|
1015
|
+
featureName: "lease",
|
|
1016
|
+
entities: {},
|
|
1017
|
+
screens: [editScreen("item")],
|
|
1018
|
+
};
|
|
1019
|
+
renderWithFeatures(
|
|
1020
|
+
{
|
|
1021
|
+
...sectionWithRowClick,
|
|
1022
|
+
rowActions: [{ kind: "navigate", id: "edit", label: "custom-edit", screen: "item-edit" }],
|
|
1023
|
+
},
|
|
1024
|
+
dispatcher,
|
|
1025
|
+
[schema],
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
1029
|
+
expect(rtlScreen.queryAllByTestId("action-edit-r1")).toHaveLength(1);
|
|
1030
|
+
expect(rtlScreen.getByTestId("action-edit-r1").textContent).toBe("custom-edit");
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test("no rowClick.entity → no default edit action", async () => {
|
|
1034
|
+
const { dispatcher } = stubDispatcher();
|
|
1035
|
+
const schema: FeatureSchema = {
|
|
1036
|
+
featureName: "lease",
|
|
1037
|
+
entities: {},
|
|
1038
|
+
screens: [editScreen("item")],
|
|
1039
|
+
};
|
|
1040
|
+
renderWithFeatures({ ...sectionWithRowClick, rowClick: undefined }, dispatcher, [schema]);
|
|
1041
|
+
|
|
1042
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
1043
|
+
expect(rtlScreen.queryByTestId("action-edit-r1")).toBeNull();
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test("access denied to the entityEdit screen → no default edit action", async () => {
|
|
1047
|
+
const { dispatcher } = stubDispatcher();
|
|
1048
|
+
const schema: FeatureSchema = {
|
|
1049
|
+
featureName: "lease",
|
|
1050
|
+
entities: {},
|
|
1051
|
+
screens: [editScreen("item", ["admin"])],
|
|
1052
|
+
};
|
|
1053
|
+
renderWithFeatures(sectionWithRowClick, dispatcher, [schema]);
|
|
1054
|
+
|
|
1055
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
1056
|
+
expect(rtlScreen.queryByTestId("action-edit-r1")).toBeNull();
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
@@ -11,15 +11,22 @@ import type {
|
|
|
11
11
|
Translate,
|
|
12
12
|
} from "@cosmicdrift/kumiko-headless";
|
|
13
13
|
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
14
|
+
import { useAppFeatures } from "../app/app-features-context";
|
|
14
15
|
import {
|
|
15
16
|
buildFilterFacets,
|
|
16
17
|
buildFilterPayload,
|
|
17
18
|
resolveProjectionFacetSpecs,
|
|
18
19
|
} from "../app/list-facets";
|
|
19
20
|
import { useNav } from "../app/nav";
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
buildDefaultEditRowAction,
|
|
23
|
+
buildProjectionRowActions,
|
|
24
|
+
runProjectionRowNavigate,
|
|
25
|
+
} from "../app/row-actions";
|
|
26
|
+
import { findEditScreenFor } from "../app/screen-access";
|
|
21
27
|
import { dispatcherErrorText } from "../app/write-failed-error";
|
|
22
28
|
import { useOptionalDispatcher } from "../context/dispatcher-context";
|
|
29
|
+
import { useUserRoles } from "../context/user-roles-context";
|
|
23
30
|
import type { ListSort } from "../hooks/use-list-url-state";
|
|
24
31
|
import { useQuery } from "../hooks/use-query";
|
|
25
32
|
import { useTranslation } from "../i18n";
|
|
@@ -79,6 +86,17 @@ export function RelatedListSection({
|
|
|
79
86
|
const effectiveTranslate = translate ?? t;
|
|
80
87
|
const nav = useNav();
|
|
81
88
|
const dispatcher = useOptionalDispatcher();
|
|
89
|
+
const appFeatures = useAppFeatures();
|
|
90
|
+
const userRoles = useUserRoles();
|
|
91
|
+
const defaultEditScreen = useMemo(() => {
|
|
92
|
+
const targetEntity = section.rowClick?.entity;
|
|
93
|
+
if (targetEntity === undefined) return undefined;
|
|
94
|
+
return findEditScreenFor(targetEntity, appFeatures, userRoles);
|
|
95
|
+
}, [appFeatures, section.rowClick, userRoles]);
|
|
96
|
+
const defaultEditRowAction = useMemo(
|
|
97
|
+
() => buildDefaultEditRowAction(defaultEditScreen, section.rowClick?.idColumn ?? "id"),
|
|
98
|
+
[defaultEditScreen, section.rowClick],
|
|
99
|
+
);
|
|
82
100
|
|
|
83
101
|
const entity = useMemo(() => synthesizeRelatedListEntity(section.columns), [section.columns]);
|
|
84
102
|
const listScreen = useMemo(
|
|
@@ -195,8 +213,17 @@ export function RelatedListSection({
|
|
|
195
213
|
nav,
|
|
196
214
|
refetch: rowsQuery.refetch,
|
|
197
215
|
openDrawer: onOpenDrawer,
|
|
216
|
+
defaultEditRowAction,
|
|
198
217
|
}),
|
|
199
|
-
[
|
|
218
|
+
[
|
|
219
|
+
section.rowActions,
|
|
220
|
+
effectiveTranslate,
|
|
221
|
+
dispatcher,
|
|
222
|
+
nav,
|
|
223
|
+
rowsQuery.refetch,
|
|
224
|
+
onOpenDrawer,
|
|
225
|
+
defaultEditRowAction,
|
|
226
|
+
],
|
|
200
227
|
);
|
|
201
228
|
|
|
202
229
|
// A truncated fetch means `sortedRows` is a sort of a partial set, not of
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
EntityEditScreenDefinition,
|
|
3
3
|
FieldCondition,
|
|
4
|
+
PlatformComponent,
|
|
4
5
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
6
|
import {
|
|
6
7
|
evalFieldCondition,
|
|
@@ -190,6 +191,53 @@ function ExtensionSectionMount({
|
|
|
190
191
|
);
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
// Resolves an entityEdit slot's (`screen.slots.header` / `.footer`)
|
|
195
|
+
// `{ react: { __component: "X" } }` marker via the same ExtensionSectionsProvider
|
|
196
|
+
// registry as ExtensionSectionMount / ListHeaderSlotMount and mounts it. One
|
|
197
|
+
// component for both slots — header passes no hasUnsavedChanges/wizardStep,
|
|
198
|
+
// footer does. Own component for rules-of-hooks (useExtensionSectionComponent
|
|
199
|
+
// must not run conditionally inside RenderEdit's render body).
|
|
200
|
+
function EditSlotMount({
|
|
201
|
+
slot,
|
|
202
|
+
slotName,
|
|
203
|
+
screenId,
|
|
204
|
+
entityName,
|
|
205
|
+
entityId,
|
|
206
|
+
values,
|
|
207
|
+
hasUnsavedChanges,
|
|
208
|
+
wizardStep,
|
|
209
|
+
}: {
|
|
210
|
+
readonly slot: PlatformComponent;
|
|
211
|
+
readonly slotName: "header" | "footer";
|
|
212
|
+
readonly screenId: string;
|
|
213
|
+
readonly entityName: string;
|
|
214
|
+
readonly entityId: string | null;
|
|
215
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
216
|
+
readonly hasUnsavedChanges?: boolean;
|
|
217
|
+
readonly wizardStep?: { readonly index: number; readonly isLast: boolean };
|
|
218
|
+
}): ReactNode {
|
|
219
|
+
const name = extensionSectionName(slot);
|
|
220
|
+
const Component = useExtensionSectionComponent(name);
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (name !== undefined && Component === undefined) {
|
|
223
|
+
// biome-ignore lint/suspicious/noConsole: dev warning for a setup error
|
|
224
|
+
console.warn(
|
|
225
|
+
`[kumiko] Edit ${slotName} slot component "${name}" on screen "${screenId}" is not registered in clientFeatures.extensionSectionComponents — the ${slotName} slot renders nothing.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}, [name, Component, screenId, slotName]);
|
|
229
|
+
if (Component === undefined) return null;
|
|
230
|
+
return (
|
|
231
|
+
<Component
|
|
232
|
+
entityName={entityName}
|
|
233
|
+
entityId={entityId}
|
|
234
|
+
values={values}
|
|
235
|
+
{...(hasUnsavedChanges !== undefined && { hasUnsavedChanges })}
|
|
236
|
+
{...(wizardStep !== undefined && { wizardStep })}
|
|
237
|
+
/>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
193
241
|
export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
194
242
|
props: RenderEditProps<TValues, TCtx>,
|
|
195
243
|
): ReactNode {
|
|
@@ -1016,6 +1064,30 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1016
1064
|
)}
|
|
1017
1065
|
</>
|
|
1018
1066
|
);
|
|
1067
|
+
const headerSlot = screen.slots?.header;
|
|
1068
|
+
const headerSlotMount =
|
|
1069
|
+
headerSlot !== undefined ? (
|
|
1070
|
+
<EditSlotMount
|
|
1071
|
+
slot={headerSlot}
|
|
1072
|
+
slotName="header"
|
|
1073
|
+
screenId={screen.id}
|
|
1074
|
+
entityName={vm.entityName}
|
|
1075
|
+
entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
|
|
1076
|
+
values={snapshot.values}
|
|
1077
|
+
/>
|
|
1078
|
+
) : undefined;
|
|
1079
|
+
// Slot renders above the caller's own headerRegion; without slots.header
|
|
1080
|
+
// this is bit-identical to the plain headerRegion prop below.
|
|
1081
|
+
const formHeaderRegion =
|
|
1082
|
+
headerSlotMount !== undefined ? (
|
|
1083
|
+
<>
|
|
1084
|
+
{headerSlotMount}
|
|
1085
|
+
{headerRegion}
|
|
1086
|
+
</>
|
|
1087
|
+
) : (
|
|
1088
|
+
headerRegion
|
|
1089
|
+
);
|
|
1090
|
+
const footerSlot = screen.slots?.footer;
|
|
1019
1091
|
// Mirrors every branch inside formActions below — without this guard
|
|
1020
1092
|
// DefaultForm renders an empty footer strip (border + padding, no content)
|
|
1021
1093
|
// on read-only detail screens, since `actions` would otherwise always be
|
|
@@ -1024,7 +1096,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1024
1096
|
(isWizard && currentStep > 0) ||
|
|
1025
1097
|
(isWizard && !isLastWizardStep) ||
|
|
1026
1098
|
((isFormEditable || hasExtensionRegistrations || isFieldless) &&
|
|
1027
|
-
(!isWizard || isLastWizardStep))
|
|
1099
|
+
(!isWizard || isLastWizardStep)) ||
|
|
1100
|
+
footerSlot !== undefined;
|
|
1028
1101
|
const formActions = (
|
|
1029
1102
|
<>
|
|
1030
1103
|
{isWizard && currentStep > 0 && (
|
|
@@ -1038,6 +1111,18 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1038
1111
|
{translate("kumiko.actions.back")}
|
|
1039
1112
|
</Button>
|
|
1040
1113
|
)}
|
|
1114
|
+
{footerSlot !== undefined && (
|
|
1115
|
+
<EditSlotMount
|
|
1116
|
+
slot={footerSlot}
|
|
1117
|
+
slotName="footer"
|
|
1118
|
+
screenId={screen.id}
|
|
1119
|
+
entityName={vm.entityName}
|
|
1120
|
+
entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
|
|
1121
|
+
values={snapshot.values}
|
|
1122
|
+
hasUnsavedChanges={snapshot.isDirty || extensionDirty}
|
|
1123
|
+
wizardStep={isWizard ? { index: currentStep, isLast: isLastWizardStep } : undefined}
|
|
1124
|
+
/>
|
|
1125
|
+
)}
|
|
1041
1126
|
{isWizard && !isLastWizardStep && (
|
|
1042
1127
|
<Button
|
|
1043
1128
|
type="submit"
|
|
@@ -1108,7 +1193,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1108
1193
|
testId="render-edit-form"
|
|
1109
1194
|
stickyActions={isWizard}
|
|
1110
1195
|
{...(screen.layout.width !== undefined && { width: screen.layout.width })}
|
|
1111
|
-
{...(
|
|
1196
|
+
{...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion })}
|
|
1112
1197
|
{...(fillHeight && { fillHeight })}
|
|
1113
1198
|
{...(hideSectionTitles === true && { chromeless: true })}
|
|
1114
1199
|
>
|
package/src/primitives.tsx
CHANGED
|
@@ -476,6 +476,9 @@ export type InputProps =
|
|
|
476
476
|
/** Read-only Textarea. Nicht `disabled` — bleibt fokussier-/
|
|
477
477
|
* kopierbar (analog zu kind:"text"). */
|
|
478
478
|
readonly readOnly?: boolean;
|
|
479
|
+
/** Ctrl/Cmd+Enter submits instead of inserting a newline. Plain
|
|
480
|
+
* Enter still inserts a newline. */
|
|
481
|
+
readonly onSubmitShortcut?: () => void;
|
|
479
482
|
};
|
|
480
483
|
|
|
481
484
|
// Sort-Wire-Format. `null`-State unterscheidet "User hat noch nichts
|