@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.259.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.259.0",
19
- "@cosmicdrift/kumiko-headless": "0.259.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.259.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
- const entityId = extractCreatedId(result.data);
706
- nav.navigate({
707
- screenId: lastSegment(screen.redirect),
708
- ...(entityId !== undefined && { entityId }),
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
- nav.navigate({ screenId: lastSegment(screen.redirect) });
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
- if (screen.rowActions === undefined) return undefined;
1676
- return screen.rowActions
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
- [screen.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch, openDrawer],
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
- for (const feature of appFeatures) {
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
- <Heading variant="page" testId="kumiko-screen-projection-detail-title">
2744
- {String(record[header.title] ?? "")}
2745
- </Heading>
2746
- {(header.subtitle !== undefined || header.status !== undefined) && (
2808
+ {header.status !== undefined ? (
2747
2809
  <Grid columns="auto">
2748
- {header.subtitle !== undefined &&
2749
- (subtitleHref !== undefined ? (
2750
- <Link
2751
- href={subtitleHref}
2752
- target="_blank"
2753
- testId="kumiko-screen-projection-detail-subtitle"
2754
- >
2755
- {String(record[header.subtitle] ?? "")}
2756
- </Link>
2757
- ) : (
2758
- <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2759
- {String(record[header.subtitle] ?? "")}
2760
- </Text>
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 redirectScreen = redirectScreenTarget(screen.redirect);
2943
- const idField = typeof screen.redirect === "string" ? "id" : screen.redirect.idFrom;
2944
- const targetId = lastSegment(redirectScreen);
2945
- const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
2946
- // A qualified redirect target may live in a different feature than
2947
- // this screen's own schema (fw#2485) — resolve over all mounted
2948
- // features (own schema first, cheap and provider-independent) same
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.screens, appFeatures],
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
@@ -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 } = options;
228
- if (rowActions === undefined) return undefined;
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 rowActions) {
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
- const writeAction = action;
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
  }
@@ -1,4 +1,8 @@
1
- import type { AccessRule } from "@cosmicdrift/kumiko-framework/ui-types";
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 { buildProjectionRowActions, runProjectionRowNavigate } from "../app/row-actions";
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
- [section.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch, onOpenDrawer],
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
- {...(headerRegion !== undefined && { headerRegion })}
1196
+ {...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion })}
1112
1197
  {...(fillHeight && { fillHeight })}
1113
1198
  {...(hideSectionTitles === true && { chromeless: true })}
1114
1199
  >
@@ -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