@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.
- 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/dashboard-body.tsx +1 -0
- package/src/app/extension-sections.tsx +9 -0
- package/src/app/kumiko-screen.tsx +186 -88
- package/src/app/row-actions.ts +71 -29
- package/src/app/screen-access.ts +22 -1
- package/src/app/use-embedded-screen.ts +27 -0
- 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/index.ts +2 -0
- package/src/primitives.tsx +3 -0
|
@@ -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/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";
|
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
|