@cosmicdrift/kumiko-renderer 0.318.0 → 0.320.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 +5 -5
- package/src/app/__tests__/return-to.test.ts +59 -1
- package/src/app/__tests__/row-actions-navigate-tab.test.ts +115 -5
- package/src/app/kumiko-screen.tsx +1 -4
- package/src/app/return-to.tsx +23 -3
- package/src/app/row-actions.ts +7 -11
- package/src/app/screen-access.ts +9 -21
- package/src/changes.json +12 -0
- package/src/components/related-list-section.tsx +2 -2
- package/src/i18n-defaults.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.320.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,9 +15,9 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.320.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.320.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.320.0",
|
|
21
21
|
"react": "^19.2.6",
|
|
22
22
|
"temporal-polyfill": "^0.3.2",
|
|
23
23
|
"zod": "^4.4.3"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"jsdom": "^29.1.1",
|
|
30
30
|
"react-dom": "^19.2.6",
|
|
31
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.320.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import type { FeatureSchema } from "../feature-schema";
|
|
4
|
-
import {
|
|
4
|
+
import type { NavApi, NavTarget } from "../nav";
|
|
5
|
+
import {
|
|
6
|
+
formatReturnTo,
|
|
7
|
+
navigateWithReturnTo,
|
|
8
|
+
resolveReturnTarget,
|
|
9
|
+
returnToParams,
|
|
10
|
+
splitReturnTo,
|
|
11
|
+
} from "../return-to";
|
|
12
|
+
|
|
13
|
+
// hrefFor maps entity ObjectTargets to their detail screen, mirroring resolveTarget.
|
|
14
|
+
function fakeNav(searchParams: Readonly<Record<string, string>> = {}): {
|
|
15
|
+
readonly nav: NavApi;
|
|
16
|
+
readonly navigateCalls: NavTarget[];
|
|
17
|
+
readonly setSearchParamsCalls: ReadonlyArray<Readonly<Record<string, string | null>>>;
|
|
18
|
+
} {
|
|
19
|
+
const navigateCalls: NavTarget[] = [];
|
|
20
|
+
const setSearchParamsCalls: Array<Readonly<Record<string, string | null>>> = [];
|
|
21
|
+
const hrefFor = (target: NavTarget): string =>
|
|
22
|
+
"screenId" in target
|
|
23
|
+
? `/${[target.screenId, target.entityId].filter((s) => s !== undefined).join("/")}`
|
|
24
|
+
: `/vehicle-detail/${target.id}`;
|
|
25
|
+
const nav: NavApi = {
|
|
26
|
+
route: undefined,
|
|
27
|
+
navigate: (target) => navigateCalls.push(target),
|
|
28
|
+
replace: () => {},
|
|
29
|
+
hrefFor,
|
|
30
|
+
searchParams,
|
|
31
|
+
setSearchParams: (updates) => setSearchParamsCalls.push(updates),
|
|
32
|
+
};
|
|
33
|
+
return { nav, navigateCalls, setSearchParamsCalls };
|
|
34
|
+
}
|
|
5
35
|
|
|
6
36
|
function schemaWith(featureName: string, screens: readonly ScreenDefinition[]): FeatureSchema {
|
|
7
37
|
return { featureName, entities: {}, screens };
|
|
@@ -336,3 +366,31 @@ describe("formatReturnTo / splitReturnTo", () => {
|
|
|
336
366
|
).toEqual({ returnTo: "token-list?tab=keys" });
|
|
337
367
|
});
|
|
338
368
|
});
|
|
369
|
+
|
|
370
|
+
describe("navigateWithReturnTo — ObjectTarget", () => {
|
|
371
|
+
test("target resolves away from the host: returnTo carries the host's search-params snapshot", () => {
|
|
372
|
+
const { nav, navigateCalls, setSearchParamsCalls } = fakeNav({ tab: "active" });
|
|
373
|
+
navigateWithReturnTo(nav, { entity: "vehicle", id: "abc" }, { screenId: "vehicle-list" });
|
|
374
|
+
|
|
375
|
+
expect(navigateCalls).toEqual([{ entity: "vehicle", id: "abc" }]);
|
|
376
|
+
expect(setSearchParamsCalls).toEqual([{ returnTo: "vehicle-list?tab=active" }]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("target resolves to the host itself: no returnTo", () => {
|
|
380
|
+
const { nav, setSearchParamsCalls } = fakeNav();
|
|
381
|
+
navigateWithReturnTo(
|
|
382
|
+
nav,
|
|
383
|
+
{ entity: "vehicle", id: "abc" },
|
|
384
|
+
{ screenId: "vehicle-detail", entityId: "abc" },
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(setSearchParamsCalls).toEqual([]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("no host: only params, no returnTo", () => {
|
|
391
|
+
const { nav, setSearchParamsCalls } = fakeNav();
|
|
392
|
+
navigateWithReturnTo(nav, { entity: "vehicle", id: "abc" }, undefined, { name: "Vehicle A" });
|
|
393
|
+
|
|
394
|
+
expect(setSearchParamsCalls).toEqual([{ name: "Vehicle A" }]);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
RelatedListToolbarAction,
|
|
4
|
+
RowActionNavigate,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
6
|
import type { NavApi, NavTarget } from "../nav";
|
|
4
7
|
import { RETURN_TO_PARAM, type ReturnHost } from "../return-to";
|
|
5
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildProjectionToolbarActions,
|
|
10
|
+
buildRecordActions,
|
|
11
|
+
runProjectionRowNavigate,
|
|
12
|
+
} from "../row-actions";
|
|
6
13
|
|
|
7
|
-
function recordingNav(): {
|
|
14
|
+
function recordingNav(hrefFor: (target: NavTarget) => string = () => ""): {
|
|
8
15
|
readonly nav: NavApi;
|
|
9
16
|
readonly navigateCalls: NavTarget[];
|
|
10
17
|
readonly setSearchParamsCalls: ReadonlyArray<Readonly<Record<string, string | null>>>;
|
|
@@ -15,13 +22,19 @@ function recordingNav(): {
|
|
|
15
22
|
route: undefined,
|
|
16
23
|
navigate: (target) => navigateCalls.push(target),
|
|
17
24
|
replace: () => {},
|
|
18
|
-
hrefFor
|
|
25
|
+
hrefFor,
|
|
19
26
|
searchParams: {},
|
|
20
27
|
setSearchParams: (updates) => setSearchParamsCalls.push(updates),
|
|
21
28
|
};
|
|
22
29
|
return { nav, navigateCalls, setSearchParamsCalls };
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
// hrefFor maps entity ObjectTargets to their detail screen, mirroring resolveTarget.
|
|
33
|
+
const hrefForVehicleDetail = (target: NavTarget): string =>
|
|
34
|
+
"screenId" in target
|
|
35
|
+
? `/${[target.screenId, target.entityId].filter((s) => s !== undefined).join("/")}`
|
|
36
|
+
: `/vehicle-detail/${target.id}`;
|
|
37
|
+
|
|
25
38
|
const host: ReturnHost = { screenId: "vehicle-list" };
|
|
26
39
|
const noOpenDrawer = (): void => {};
|
|
27
40
|
const noOnWriteSuccess = async (): Promise<void> => {};
|
|
@@ -74,7 +87,7 @@ describe("buildRecordActions — RowActionNavigate.tab", () => {
|
|
|
74
87
|
record: { id: "abc" },
|
|
75
88
|
translate: (key) => key,
|
|
76
89
|
nav,
|
|
77
|
-
host,
|
|
90
|
+
host: undefined,
|
|
78
91
|
dispatcher: undefined,
|
|
79
92
|
openDrawer: noOpenDrawer,
|
|
80
93
|
onWriteSuccess: noOnWriteSuccess,
|
|
@@ -85,6 +98,32 @@ describe("buildRecordActions — RowActionNavigate.tab", () => {
|
|
|
85
98
|
expect(setSearchParamsCalls[0]).toEqual({ tab: "channels" });
|
|
86
99
|
});
|
|
87
100
|
|
|
101
|
+
test("entity-target: carries returnTo when the target resolves away from the host", () => {
|
|
102
|
+
const { nav, navigateCalls, setSearchParamsCalls } = recordingNav(hrefForVehicleDetail);
|
|
103
|
+
const action: RowActionNavigate = {
|
|
104
|
+
kind: "navigate",
|
|
105
|
+
id: "view-channels",
|
|
106
|
+
label: "app.actions.viewChannels",
|
|
107
|
+
entity: "vehicle",
|
|
108
|
+
entityId: "id",
|
|
109
|
+
tab: "channels",
|
|
110
|
+
};
|
|
111
|
+
const actions = buildRecordActions({
|
|
112
|
+
actions: [action],
|
|
113
|
+
record: { id: "abc" },
|
|
114
|
+
translate: (key) => key,
|
|
115
|
+
nav,
|
|
116
|
+
host,
|
|
117
|
+
dispatcher: undefined,
|
|
118
|
+
openDrawer: noOpenDrawer,
|
|
119
|
+
onWriteSuccess: noOnWriteSuccess,
|
|
120
|
+
});
|
|
121
|
+
actions?.[0]?.onPress();
|
|
122
|
+
|
|
123
|
+
expect(navigateCalls).toEqual([{ entity: "vehicle", id: "abc" }]);
|
|
124
|
+
expect(setSearchParamsCalls).toEqual([{ tab: "channels", [RETURN_TO_PARAM]: "vehicle-list" }]);
|
|
125
|
+
});
|
|
126
|
+
|
|
88
127
|
test("no tab set: search params carry no tab key", () => {
|
|
89
128
|
const { nav, setSearchParamsCalls } = recordingNav();
|
|
90
129
|
const action: RowActionNavigate = {
|
|
@@ -111,3 +150,74 @@ describe("buildRecordActions — RowActionNavigate.tab", () => {
|
|
|
111
150
|
expect(setSearchParamsCalls[0]).not.toHaveProperty("tab");
|
|
112
151
|
});
|
|
113
152
|
});
|
|
153
|
+
|
|
154
|
+
describe("buildProjectionToolbarActions — navigate.tab", () => {
|
|
155
|
+
test("tab lands in the setSearchParams call", () => {
|
|
156
|
+
const { nav, navigateCalls, setSearchParamsCalls } = recordingNav();
|
|
157
|
+
const action: RelatedListToolbarAction = {
|
|
158
|
+
kind: "navigate",
|
|
159
|
+
id: "view-channels",
|
|
160
|
+
label: "app.actions.viewChannels",
|
|
161
|
+
screen: "vehicle-detail",
|
|
162
|
+
tab: "channels",
|
|
163
|
+
};
|
|
164
|
+
const actions = buildProjectionToolbarActions({
|
|
165
|
+
toolbarActions: [action],
|
|
166
|
+
translate: (key) => key,
|
|
167
|
+
dispatcher: undefined,
|
|
168
|
+
nav,
|
|
169
|
+
refetch: async () => {},
|
|
170
|
+
host: undefined,
|
|
171
|
+
});
|
|
172
|
+
actions?.[0]?.onTrigger();
|
|
173
|
+
|
|
174
|
+
expect(navigateCalls).toEqual([{ screenId: "vehicle-detail" }]);
|
|
175
|
+
expect(setSearchParamsCalls).toEqual([{ tab: "channels" }]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("navigatePrefill and tab both survive into the same setSearchParams call, plus returnTo with a host", () => {
|
|
179
|
+
const { nav, setSearchParamsCalls } = recordingNav();
|
|
180
|
+
const action: RelatedListToolbarAction = {
|
|
181
|
+
kind: "navigate",
|
|
182
|
+
id: "create-channel",
|
|
183
|
+
label: "app.actions.createChannel",
|
|
184
|
+
screen: "channel-create",
|
|
185
|
+
tab: "channels",
|
|
186
|
+
};
|
|
187
|
+
const actions = buildProjectionToolbarActions({
|
|
188
|
+
toolbarActions: [action],
|
|
189
|
+
translate: (key) => key,
|
|
190
|
+
dispatcher: undefined,
|
|
191
|
+
nav,
|
|
192
|
+
refetch: async () => {},
|
|
193
|
+
navigatePrefill: { vehicleId: "abc" },
|
|
194
|
+
host,
|
|
195
|
+
});
|
|
196
|
+
actions?.[0]?.onTrigger();
|
|
197
|
+
|
|
198
|
+
expect(setSearchParamsCalls).toHaveLength(1);
|
|
199
|
+
expect(setSearchParamsCalls[0]).toMatchObject({
|
|
200
|
+
tab: "channels",
|
|
201
|
+
vehicleId: "abc",
|
|
202
|
+
[RETURN_TO_PARAM]: "vehicle-list",
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("runProjectionRowNavigate — entity target carries returnTo", () => {
|
|
208
|
+
test("entity action with a host and tab: navigate gets the ObjectTarget, returnTo and tab land together", () => {
|
|
209
|
+
const { nav, navigateCalls, setSearchParamsCalls } = recordingNav(hrefForVehicleDetail);
|
|
210
|
+
const action: RowActionNavigate = {
|
|
211
|
+
kind: "navigate",
|
|
212
|
+
id: "view-channels",
|
|
213
|
+
label: "app.actions.viewChannels",
|
|
214
|
+
entity: "vehicle",
|
|
215
|
+
entityId: "id",
|
|
216
|
+
tab: "channels",
|
|
217
|
+
};
|
|
218
|
+
runProjectionRowNavigate(nav, action, { id: "abc", values: { id: "abc" } }, host);
|
|
219
|
+
|
|
220
|
+
expect(navigateCalls).toEqual([{ entity: "vehicle", id: "abc" }]);
|
|
221
|
+
expect(setSearchParamsCalls).toEqual([{ tab: "channels", [RETURN_TO_PARAM]: "vehicle-list" }]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -1951,11 +1951,8 @@ function EntityListBody({
|
|
|
1951
1951
|
action.entity === screen.entity ? String(row.values["id"] ?? "") : undefined;
|
|
1952
1952
|
const id = explicit ?? fallback ?? "";
|
|
1953
1953
|
if (id === "") return;
|
|
1954
|
-
nav.navigate({ entity: action.entity, id });
|
|
1955
1954
|
const params = navigateActionSearchParams(action, row.values);
|
|
1956
|
-
|
|
1957
|
-
nav.setSearchParams(params);
|
|
1958
|
-
}
|
|
1955
|
+
navigateWithReturnTo(nav, { entity: action.entity, id }, host, params);
|
|
1959
1956
|
} else if (action.screen !== undefined) {
|
|
1960
1957
|
// Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
|
|
1961
1958
|
// entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
|
package/src/app/return-to.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { createContext, type ReactNode, useContext, useMemo } from "react";
|
|
|
3
3
|
import { useUserRoles } from "../context/user-roles-context";
|
|
4
4
|
import { useAppFeatures } from "./app-features-context";
|
|
5
5
|
import type { FeatureSchema } from "./feature-schema";
|
|
6
|
-
import { type NavApi, type ScreenTarget, useNav } from "./nav";
|
|
6
|
+
import { type NavApi, type NavTarget, type ObjectTarget, type ScreenTarget, useNav } from "./nav";
|
|
7
7
|
import { lastSegment } from "./qn";
|
|
8
8
|
import { screenAccessAllows } from "./screen-access";
|
|
9
9
|
|
|
@@ -93,14 +93,34 @@ export function returnToParams(
|
|
|
93
93
|
return { [RETURN_TO_PARAM]: formatReturnTo(host, hostParams) };
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ObjectTarget only resolves to a path inside the NavApi impl (detailFor
|
|
97
|
+
// lookup across all features), so compare hrefs instead of the raw target.
|
|
98
|
+
function objectTargetIsHost(nav: NavApi, target: ObjectTarget, host: ReturnHost): boolean {
|
|
99
|
+
const hostTarget: ScreenTarget = {
|
|
100
|
+
screenId: host.screenId,
|
|
101
|
+
...(host.entityId !== undefined && { entityId: host.entityId }),
|
|
102
|
+
};
|
|
103
|
+
return nav.hrefFor(target) === nav.hrefFor(hostTarget);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function returnParamsFor(
|
|
107
|
+
nav: NavApi,
|
|
108
|
+
target: NavTarget,
|
|
109
|
+
host: ReturnHost | undefined,
|
|
110
|
+
): Readonly<Record<string, string>> {
|
|
111
|
+
if ("screenId" in target) return returnToParams(host, target, nav.searchParams);
|
|
112
|
+
if (host === undefined || objectTargetIsHost(nav, target, host)) return {};
|
|
113
|
+
return { [RETURN_TO_PARAM]: formatReturnTo(host, nav.searchParams) };
|
|
114
|
+
}
|
|
115
|
+
|
|
96
116
|
export function navigateWithReturnTo(
|
|
97
117
|
nav: NavApi,
|
|
98
|
-
target:
|
|
118
|
+
target: NavTarget,
|
|
99
119
|
host: ReturnHost | undefined,
|
|
100
120
|
params?: Readonly<Record<string, string | null>>,
|
|
101
121
|
): void {
|
|
102
122
|
// Snapshot before navigating — nav.searchParams still describes the host.
|
|
103
|
-
const returnParams =
|
|
123
|
+
const returnParams = returnParamsFor(nav, target, host);
|
|
104
124
|
nav.navigate(target);
|
|
105
125
|
const merged: Record<string, string | null> = {
|
|
106
126
|
...(params ?? {}),
|
package/src/app/row-actions.ts
CHANGED
|
@@ -106,11 +106,8 @@ export function runProjectionRowNavigate(
|
|
|
106
106
|
const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
|
|
107
107
|
// skip: no entityId column on this row — nothing to navigate to.
|
|
108
108
|
if (id === "") return;
|
|
109
|
-
nav.navigate({ entity: action.entity, id });
|
|
110
109
|
const params = navigateActionSearchParams(action, row.values);
|
|
111
|
-
|
|
112
|
-
nav.setSearchParams(params);
|
|
113
|
-
}
|
|
110
|
+
navigateWithReturnTo(nav, { entity: action.entity, id }, host, params);
|
|
114
111
|
} else if (action.screen !== undefined) {
|
|
115
112
|
const entityId =
|
|
116
113
|
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
@@ -320,7 +317,10 @@ function buildNavigateToolbarAction(
|
|
|
320
317
|
action.params !== undefined && record !== undefined
|
|
321
318
|
? evalRowExtractor(action.params, record)
|
|
322
319
|
: prefill;
|
|
323
|
-
const
|
|
320
|
+
const stringifiedParams =
|
|
321
|
+
resolvedParams !== undefined ? stringifyNavParams(resolvedParams) : undefined;
|
|
322
|
+
const params =
|
|
323
|
+
action.tab === undefined ? stringifiedParams : { ...stringifiedParams, tab: action.tab };
|
|
324
324
|
navigateWithReturnTo(nav, target, host, params);
|
|
325
325
|
},
|
|
326
326
|
};
|
|
@@ -450,10 +450,6 @@ function buildNavigateRecordAction(
|
|
|
450
450
|
defaultScreenTargetEntityId,
|
|
451
451
|
sameEntityScreenId,
|
|
452
452
|
} = options;
|
|
453
|
-
const runParams = (): void => {
|
|
454
|
-
const params = navigateActionSearchParams(action, record);
|
|
455
|
-
if (params !== undefined) nav.setSearchParams(params);
|
|
456
|
-
};
|
|
457
453
|
if (action.entity !== undefined) {
|
|
458
454
|
const targetEntity = action.entity;
|
|
459
455
|
const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
|
|
@@ -466,8 +462,8 @@ function buildNavigateRecordAction(
|
|
|
466
462
|
onPress: () => {
|
|
467
463
|
// No entityId on record (id === "") → nothing to navigate to.
|
|
468
464
|
if (id !== "") {
|
|
469
|
-
|
|
470
|
-
|
|
465
|
+
const params = navigateActionSearchParams(action, record);
|
|
466
|
+
navigateWithReturnTo(nav, { entity: targetEntity, id }, host, params);
|
|
471
467
|
}
|
|
472
468
|
},
|
|
473
469
|
};
|
package/src/app/screen-access.ts
CHANGED
|
@@ -1,28 +1,16 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
AccessRule,
|
|
3
2
|
EntityEditScreenDefinition,
|
|
4
3
|
FeatureSchema,
|
|
5
4
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
-
import {
|
|
5
|
+
import { isUiAccessGranted } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
6
|
import { lastSegment } from "./qn";
|
|
8
7
|
|
|
9
|
-
//
|
|
10
|
-
// filterByAccess in workspace-shell.tsx hid role-gated
|
|
11
|
-
// menu, but a direct URL/screenQn hit reached
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
// from kumiko-screen.tsx directly) so render-field.tsx can import it too
|
|
16
|
-
// without the kumiko-screen → RenderEdit → RenderField cycle.
|
|
17
|
-
export function screenAccessAllows(
|
|
18
|
-
access: AccessRule | undefined,
|
|
19
|
-
userRoles: readonly string[] | undefined,
|
|
20
|
-
): boolean {
|
|
21
|
-
if (!access) return true;
|
|
22
|
-
if ("openToAll" in access) return isOpenToAllGranted(access);
|
|
23
|
-
if (userRoles === undefined) return false;
|
|
24
|
-
return access.roles.some((role) => userRoles.includes(role));
|
|
25
|
-
}
|
|
8
|
+
// Shared UI-visibility predicate for the screen-render path (#1203 — nav
|
|
9
|
+
// filtering via filterByAccess in workspace-shell.tsx hid role-gated
|
|
10
|
+
// screens from the menu, but a direct URL/screenQn hit reached
|
|
11
|
+
// KumikoScreen unchecked). Public name kept as an alias of the shared
|
|
12
|
+
// predicate that headless/nav and workspace-shell use as well.
|
|
13
|
+
export { isUiAccessGranted as screenAccessAllows };
|
|
26
14
|
|
|
27
15
|
// A navigate target (metric click) declares no access rule of its own — the
|
|
28
16
|
// destination screen already does, so the jump is offered exactly when the
|
|
@@ -49,7 +37,7 @@ export function navigateTargetAllows(
|
|
|
49
37
|
// First match IS the destination: short ids are globally unique
|
|
50
38
|
// (validateScreenShortIdCollisions) and both the router and
|
|
51
39
|
// resolveTarget take the first hit.
|
|
52
|
-
if (isTarget) return
|
|
40
|
+
if (isTarget) return isUiAccessGranted(candidate.access, userRoles);
|
|
53
41
|
}
|
|
54
42
|
}
|
|
55
43
|
// Unresolvable target — the boot-validator doesn't check metric navigate
|
|
@@ -67,7 +55,7 @@ export function findEditScreenFor(
|
|
|
67
55
|
for (const feature of appFeatures) {
|
|
68
56
|
const match = feature.screens.find(
|
|
69
57
|
(s): s is EntityEditScreenDefinition =>
|
|
70
|
-
s.type === "entityEdit" && s.entity === entity &&
|
|
58
|
+
s.type === "entityEdit" && s.entity === entity && isUiAccessGranted(s.access, userRoles),
|
|
71
59
|
);
|
|
72
60
|
if (match !== undefined) return match;
|
|
73
61
|
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.320.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "navigateWithReturnTo carries returnTo for entity ObjectTargets",
|
|
6
|
+
"detail": "navigateWithReturnTo now accepts a NavTarget (ScreenTarget or ObjectTarget),\nnot just ScreenTarget. For an entity target it compares the resolved href\nagainst the host's to detect a self-navigate (no returnTo) and otherwise\nsets returnTo to the host snapshot, same as the existing screen-target\npath. runProjectionRowNavigate, buildNavigateRecordAction, EntityListBody's\nrow navigate and related-list-section's legacy rowClick now go through\nthis for entity actions."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.320.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Toolbar navigate actions merge `tab` into the search params",
|
|
12
|
+
"detail": "buildNavigateToolbarAction (shared by entityList, projectionList and\nrelatedList-section toolbars) sets `tab` in the same params object as any\ndeclared `params`/prefill, so it lands in the single navigateWithReturnTo\ncall next to returnTo."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.318.0",
|
|
4
16
|
"type": "improvement",
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "../app/list-facets";
|
|
21
21
|
import { useNav } from "../app/nav";
|
|
22
22
|
import { ReferenceFacetBridges, type ReferenceFacetOption } from "../app/reference-facet-bridge";
|
|
23
|
-
import { useReturnHost } from "../app/return-to";
|
|
23
|
+
import { navigateWithReturnTo, useReturnHost } from "../app/return-to";
|
|
24
24
|
import {
|
|
25
25
|
buildDefaultEditRowAction,
|
|
26
26
|
buildProjectionRowActions,
|
|
@@ -252,7 +252,7 @@ export function RelatedListSection({
|
|
|
252
252
|
? (row: ListRowViewModel) => {
|
|
253
253
|
const id = String(row.values[rowClick.idColumn ?? "id"] ?? "");
|
|
254
254
|
if (id === "") return;
|
|
255
|
-
nav
|
|
255
|
+
navigateWithReturnTo(nav, { entity: rowClick.entity, id }, host);
|
|
256
256
|
}
|
|
257
257
|
: rowClickAction !== undefined
|
|
258
258
|
? (row: ListRowViewModel) => runProjectionRowNavigate(nav, rowClickAction, row, host)
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -82,6 +82,12 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
82
82
|
|
|
83
83
|
"kumiko.dashboard.filter.all": "All",
|
|
84
84
|
|
|
85
|
+
"kumiko.app-boot.loading": "Loading…",
|
|
86
|
+
"kumiko.app-boot.unauthorized": "You need to sign in to see this.",
|
|
87
|
+
"kumiko.app-boot.failed": "Couldn't load the app. Check your connection and try again.",
|
|
88
|
+
"kumiko.app-boot.no-open-screen":
|
|
89
|
+
"This app has no screen that's accessible without a role restriction.",
|
|
90
|
+
|
|
85
91
|
"kumiko.widget.loading": "Loading…",
|
|
86
92
|
"kumiko.widget.error.title": "Couldn't load.",
|
|
87
93
|
|