@cosmicdrift/kumiko-renderer 0.258.1 → 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.
@@ -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
  >
package/src/index.ts CHANGED
@@ -81,6 +81,8 @@ export {
81
81
  useNav,
82
82
  } from "./app/nav";
83
83
  export { lastSegment } from "./app/qn";
84
+ export type { EmbeddedScreenTarget } from "./app/use-embedded-screen";
85
+ export { useEmbeddedScreen } from "./app/use-embedded-screen";
84
86
  export type { VariableChipsProps } from "./app/variable-chips";
85
87
  export { VariableChips } from "./app/variable-chips";
86
88
  export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error";
@@ -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