@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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/__tests__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
- package/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { EntityListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
3
|
import type { WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
|
|
3
4
|
import {
|
|
4
5
|
createStaticLocaleResolver,
|
|
6
|
+
DispatcherProvider,
|
|
7
|
+
KumikoScreen,
|
|
5
8
|
LocaleProvider,
|
|
6
9
|
NavProvider,
|
|
7
10
|
PrimitivesProvider,
|
|
@@ -17,7 +20,7 @@ import {
|
|
|
17
20
|
} from "../layout/workspace-shell";
|
|
18
21
|
import { WorkspaceSwitcher } from "../layout/workspace-switcher";
|
|
19
22
|
import { defaultPrimitives } from "../primitives";
|
|
20
|
-
import { fireEvent, render, screen } from "./test-utils";
|
|
23
|
+
import { createMockDispatcher, fireEvent, render, screen } from "./test-utils";
|
|
21
24
|
|
|
22
25
|
// jsdom shares window.history across tests in the same file. Reset to /
|
|
23
26
|
// before each render so URL-driven workspace state from one test doesn't
|
|
@@ -57,7 +60,6 @@ function ws(
|
|
|
57
60
|
openToAll?: boolean;
|
|
58
61
|
isDefault?: boolean;
|
|
59
62
|
navMembers?: readonly string[];
|
|
60
|
-
navigation?: "nav" | "tree";
|
|
61
63
|
} = {},
|
|
62
64
|
): WorkspaceSchema {
|
|
63
65
|
const access = options.openToAll
|
|
@@ -72,7 +74,6 @@ function ws(
|
|
|
72
74
|
...(options.order !== undefined && { order: options.order }),
|
|
73
75
|
...(access !== undefined && { access }),
|
|
74
76
|
...(options.isDefault === true && { default: true }),
|
|
75
|
-
...(options.navigation !== undefined && { navigation: options.navigation }),
|
|
76
77
|
},
|
|
77
78
|
navMembers: options.navMembers ?? [],
|
|
78
79
|
};
|
|
@@ -329,10 +330,11 @@ describe("WorkspaceShell", () => {
|
|
|
329
330
|
<div>content</div>
|
|
330
331
|
</WorkspaceShell>,
|
|
331
332
|
);
|
|
332
|
-
// admin → system + orders, NOT tours
|
|
333
|
-
|
|
334
|
-
expect(screen.
|
|
335
|
-
expect(screen.
|
|
333
|
+
// admin → system + orders, NOT tours. getAllByText: die shadcn-Sidebar
|
|
334
|
+
// rendert Desktop + Mobile-Sheet, jeder Nav-Eintrag kommt also 2× vor.
|
|
335
|
+
expect(screen.getAllByText("System").length).toBeGreaterThan(0);
|
|
336
|
+
expect(screen.getAllByText("Orders").length).toBeGreaterThan(0);
|
|
337
|
+
expect(screen.queryAllByText("Tours").length).toBe(0);
|
|
336
338
|
});
|
|
337
339
|
|
|
338
340
|
test("clicking the dispatch tab swaps the visible nav set", () => {
|
|
@@ -346,10 +348,10 @@ describe("WorkspaceShell", () => {
|
|
|
346
348
|
</WorkspaceShell>,
|
|
347
349
|
);
|
|
348
350
|
fireEvent.click(screen.getByTestId("workspace-tab-dispatch"));
|
|
349
|
-
// dispatch → orders + tours, NOT system
|
|
350
|
-
expect(screen.
|
|
351
|
-
expect(screen.
|
|
352
|
-
expect(screen.
|
|
351
|
+
// dispatch → orders + tours, NOT system (getAllByText: Desktop + Mobile-Sheet)
|
|
352
|
+
expect(screen.getAllByText("Orders").length).toBeGreaterThan(0);
|
|
353
|
+
expect(screen.getAllByText("Tours").length).toBeGreaterThan(0);
|
|
354
|
+
expect(screen.queryAllByText("System").length).toBe(0);
|
|
353
355
|
});
|
|
354
356
|
|
|
355
357
|
test("a driver lands on the driver workspace (their only one)", () => {
|
|
@@ -365,9 +367,9 @@ describe("WorkspaceShell", () => {
|
|
|
365
367
|
// Only one workspace → switcher renders nothing, but the nav still
|
|
366
368
|
// shows that workspace's members.
|
|
367
369
|
expect(screen.queryByTestId("workspace-tab-driver")).toBeNull();
|
|
368
|
-
expect(screen.
|
|
369
|
-
expect(screen.
|
|
370
|
-
expect(screen.
|
|
370
|
+
expect(screen.getAllByText("Tours").length).toBeGreaterThan(0);
|
|
371
|
+
expect(screen.queryAllByText("System").length).toBe(0);
|
|
372
|
+
expect(screen.queryAllByText("Orders").length).toBe(0);
|
|
371
373
|
});
|
|
372
374
|
|
|
373
375
|
test("schema without workspaces falls back to plain rendering (all navs visible)", () => {
|
|
@@ -710,8 +712,9 @@ describe("WorkspaceShell — AppSchema (multi-feature)", () => {
|
|
|
710
712
|
<div>content</div>
|
|
711
713
|
</WorkspaceShell>,
|
|
712
714
|
);
|
|
713
|
-
|
|
714
|
-
expect(
|
|
715
|
+
const footers = screen.getAllByTestId("sidebar-footer");
|
|
716
|
+
expect(footers.length).toBeGreaterThan(0);
|
|
717
|
+
expect(footers[0]?.textContent).toBe("v1.2.3");
|
|
715
718
|
});
|
|
716
719
|
|
|
717
720
|
test("ohne sidebarFooter-Prop rendert die Sidebar ohne Footer-Slot (default)", () => {
|
|
@@ -772,26 +775,23 @@ describe("WorkspaceShell — AppSchema (multi-feature)", () => {
|
|
|
772
775
|
});
|
|
773
776
|
|
|
774
777
|
// ---------------------------------------------------------------------------
|
|
775
|
-
// EINE Nav
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
// WorkspaceDefinition). Im Content-Bereich ersetzt ein aktives target
|
|
779
|
-
// (aus den nav.searchParams) den RoutedScreen durch den EditorPanel.
|
|
778
|
+
// EINE Nav. WorkspaceShell rendert IMMER NavTree. Im Content-Bereich
|
|
779
|
+
// ersetzt ein aktives target (aus den nav.searchParams) den RoutedScreen
|
|
780
|
+
// durch den EditorPanel.
|
|
780
781
|
// ---------------------------------------------------------------------------
|
|
781
782
|
|
|
782
|
-
describe("WorkspaceShell — EINE Nav
|
|
783
|
-
test(
|
|
783
|
+
describe("WorkspaceShell — EINE Nav", () => {
|
|
784
|
+
test("workspace rendert NavTree", () => {
|
|
784
785
|
const schema = {
|
|
785
786
|
featureName: "demo",
|
|
786
787
|
entities: {},
|
|
787
788
|
screens: [],
|
|
788
789
|
navs: [{ id: "list", label: "List" }],
|
|
789
790
|
workspaces: [
|
|
790
|
-
ws("
|
|
791
|
-
label: "
|
|
791
|
+
ws("admin", {
|
|
792
|
+
label: "Admin",
|
|
792
793
|
openToAll: true,
|
|
793
794
|
isDefault: true,
|
|
794
|
-
navigation: "tree",
|
|
795
795
|
navMembers: ["demo:nav:list"],
|
|
796
796
|
}),
|
|
797
797
|
],
|
|
@@ -802,13 +802,16 @@ describe("WorkspaceShell — EINE Nav (Visual-Tree gefaltet)", () => {
|
|
|
802
802
|
<div>content</div>
|
|
803
803
|
</WorkspaceShell>,
|
|
804
804
|
);
|
|
805
|
-
// NavTree rendert das nav-Item — auch bei navigation:"tree" (No-op).
|
|
806
805
|
expect(screen.getByText("List")).toBeTruthy();
|
|
807
|
-
// Der alte VisualTree-Stub existiert nicht mehr.
|
|
808
|
-
expect(screen.queryByLabelText("Visual Tree (no providers)")).toBeNull();
|
|
809
806
|
});
|
|
810
807
|
|
|
811
|
-
test("
|
|
808
|
+
test("aktives target in der URL rendert EditorPanel statt der screen-children", () => {
|
|
809
|
+
// Klick auf einen target-Knoten persistiert das target in den
|
|
810
|
+
// searchParams (?t=feat:action&a_*=...). WorkspaceShell rendert dann
|
|
811
|
+
// den EditorPanel statt des RoutedScreen. Voller workspace+screen-Pfad,
|
|
812
|
+
// damit der default-fill-Effect nicht replaced (sonst Verlust der
|
|
813
|
+
// searchParams). Kein Resolver registriert → EditorPanel-Fallback.
|
|
814
|
+
window.history.replaceState(null, "", "/admin/x?t=demo:edit&a_id=1");
|
|
812
815
|
const schema = {
|
|
813
816
|
featureName: "demo",
|
|
814
817
|
entities: {},
|
|
@@ -829,39 +832,91 @@ describe("WorkspaceShell — EINE Nav (Visual-Tree gefaltet)", () => {
|
|
|
829
832
|
<div>content</div>
|
|
830
833
|
</WorkspaceShell>,
|
|
831
834
|
);
|
|
832
|
-
|
|
835
|
+
// EditorPanel-Fallback (kein Resolver für demo:edit) — beweist target→Editor.
|
|
836
|
+
expect(screen.getByRole("heading", { name: "Editor" })).toBeTruthy();
|
|
837
|
+
// Die screen-children sind ersetzt, nicht zusätzlich gerendert.
|
|
838
|
+
expect(screen.queryByText("content")).toBeNull();
|
|
833
839
|
});
|
|
834
840
|
|
|
835
|
-
test("
|
|
836
|
-
// Klick auf einen target-Knoten persistiert das target in den
|
|
837
|
-
// searchParams (?t=feat:action&a_*=...). WorkspaceShell rendert dann
|
|
838
|
-
// den EditorPanel statt des RoutedScreen. Voller workspace+screen-Pfad,
|
|
839
|
-
// damit der default-fill-Effect nicht replaced (sonst Verlust der
|
|
840
|
-
// searchParams). Kein Resolver registriert → EditorPanel-Fallback.
|
|
841
|
-
window.history.replaceState(null, "", "/admin/x?t=demo:edit&a_id=1");
|
|
841
|
+
test("navBadges pass through to NavTree leaf items", () => {
|
|
842
842
|
const schema = {
|
|
843
|
-
featureName: "
|
|
843
|
+
featureName: "bmc",
|
|
844
844
|
entities: {},
|
|
845
845
|
screens: [],
|
|
846
|
-
navs: [
|
|
846
|
+
navs: [
|
|
847
|
+
{ id: "system", label: "System", screen: "bmc:screen:system" },
|
|
848
|
+
{ id: "orders", label: "Orders", screen: "bmc:screen:orders" },
|
|
849
|
+
],
|
|
847
850
|
workspaces: [
|
|
848
851
|
ws("admin", {
|
|
849
852
|
label: "Admin",
|
|
850
|
-
|
|
853
|
+
roles: ["admin"],
|
|
854
|
+
order: 1,
|
|
851
855
|
isDefault: true,
|
|
852
|
-
navMembers: ["
|
|
856
|
+
navMembers: ["bmc:nav:system", "bmc:nav:orders"],
|
|
853
857
|
}),
|
|
854
858
|
],
|
|
855
859
|
} as const;
|
|
856
860
|
|
|
857
861
|
renderShell(
|
|
858
|
-
<WorkspaceShell
|
|
862
|
+
<WorkspaceShell
|
|
863
|
+
brand={<div>Brand</div>}
|
|
864
|
+
schema={schema}
|
|
865
|
+
user={{ id: "u1", roles: ["admin"] }}
|
|
866
|
+
navBadges={new Map([["orders", <span>3</span>]])}
|
|
867
|
+
>
|
|
859
868
|
<div>content</div>
|
|
860
869
|
</WorkspaceShell>,
|
|
861
870
|
);
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
expect(
|
|
871
|
+
const ordersItems = screen.getAllByText("Orders");
|
|
872
|
+
const systemItems = screen.getAllByText("System");
|
|
873
|
+
expect(ordersItems.some((el) => el.closest("li")?.textContent?.includes("3"))).toBe(true);
|
|
874
|
+
expect(systemItems.every((el) => !el.closest("li")?.textContent?.includes("3"))).toBe(true);
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// WorkspaceShell threads `user.roles` into a UserRolesProvider around
|
|
879
|
+
// children (#1203) — KumikoScreen reads that to gate role-restricted
|
|
880
|
+
// screens at render time, not just via nav/workspace-switcher filtering.
|
|
881
|
+
describe("WorkspaceShell wires user.roles into children for screen-level access (#1203)", () => {
|
|
882
|
+
const restrictedScreen: EntityListScreenDefinition = {
|
|
883
|
+
id: "restricted",
|
|
884
|
+
type: "entityList",
|
|
885
|
+
entity: "x",
|
|
886
|
+
columns: [],
|
|
887
|
+
access: { roles: ["admin"] },
|
|
888
|
+
};
|
|
889
|
+
const schema = {
|
|
890
|
+
featureName: "bmc",
|
|
891
|
+
entities: {},
|
|
892
|
+
screens: [restrictedScreen],
|
|
893
|
+
navs: [],
|
|
894
|
+
workspaces: [],
|
|
895
|
+
} as const;
|
|
896
|
+
|
|
897
|
+
test("no user prop → role-gated screen child shows access-denied", () => {
|
|
898
|
+
renderShell(
|
|
899
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
900
|
+
<WorkspaceShell brand={<div>Brand</div>} schema={schema}>
|
|
901
|
+
<KumikoScreen schema={schema} qn="bmc:screen:restricted" />
|
|
902
|
+
</WorkspaceShell>
|
|
903
|
+
</DispatcherProvider>,
|
|
904
|
+
);
|
|
905
|
+
expect(screen.getByTestId("kumiko-screen-access-denied")).toBeTruthy();
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("user prop with matching role → role-gated screen child renders", () => {
|
|
909
|
+
renderShell(
|
|
910
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
911
|
+
<WorkspaceShell
|
|
912
|
+
brand={<div>Brand</div>}
|
|
913
|
+
schema={schema}
|
|
914
|
+
user={{ id: "u1", roles: ["admin"] }}
|
|
915
|
+
>
|
|
916
|
+
<KumikoScreen schema={schema} qn="bmc:screen:restricted" />
|
|
917
|
+
</WorkspaceShell>
|
|
918
|
+
</DispatcherProvider>,
|
|
919
|
+
);
|
|
920
|
+
expect(screen.queryByTestId("kumiko-screen-access-denied")).toBeNull();
|
|
866
921
|
});
|
|
867
922
|
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Pins the XSS-escaping behavior for tenant-/pattern-authored content
|
|
3
|
+
// (kumiko-framework#1321). renderer-web has no dangerouslySetInnerHTML
|
|
4
|
+
// and no field-renderer path that wires tenant data into href/src — this
|
|
5
|
+
// test guards JSX auto-escaping against future regressions (e.g. if
|
|
6
|
+
// someone adds rich-text/markdown support via dangerouslySetInnerHTML),
|
|
7
|
+
// and pins the Link primitive's scheme guard (kumiko-framework#1365)
|
|
8
|
+
// against javascript:/data: hrefs as defense-in-depth.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { defaultPrimitives } from "../primitives";
|
|
12
|
+
import { StatCard } from "../widgets/stat";
|
|
13
|
+
import { render, screen } from "./test-utils";
|
|
14
|
+
|
|
15
|
+
const PAYLOAD = '<script>window.__xss = true;</script><img src=x onerror="window.__xss = true">';
|
|
16
|
+
const UNSAFE_HREFS = [
|
|
17
|
+
"javascript:window.__xss = true",
|
|
18
|
+
"data:text/html,<script>window.__xss=true</script>",
|
|
19
|
+
// Browsers strip embedded control/whitespace chars before scheme
|
|
20
|
+
// detection — a tab/newline mid-scheme must not bypass the check.
|
|
21
|
+
"java\tscript:window.__xss = true",
|
|
22
|
+
"java\nscript:window.__xss = true",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
describe("XSS-safety (tenant-authored content)", () => {
|
|
26
|
+
test("Text-Primitive rendert Payload als Text, nicht als HTML", () => {
|
|
27
|
+
render(<defaultPrimitives.Text testId="txt">{PAYLOAD}</defaultPrimitives.Text>);
|
|
28
|
+
const node = screen.getByTestId("txt");
|
|
29
|
+
expect(node.textContent).toBe(PAYLOAD);
|
|
30
|
+
expect(node.querySelector("script")).toBeNull();
|
|
31
|
+
expect(node.querySelector("img")).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("StatCard-Widget rendert label/value/sub als Text, nicht als HTML", () => {
|
|
35
|
+
render(<StatCard testId="stat" label={PAYLOAD} value={PAYLOAD} sub={PAYLOAD} />);
|
|
36
|
+
const node = screen.getByTestId("stat");
|
|
37
|
+
expect(node.querySelector("script")).toBeNull();
|
|
38
|
+
expect(node.querySelector("img")).toBeNull();
|
|
39
|
+
expect(node.textContent).toContain(PAYLOAD);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test.each(UNSAFE_HREFS)("Link-Primitive neutralisiert unsicheren href-Scheme: %s", (href) => {
|
|
43
|
+
render(
|
|
44
|
+
<defaultPrimitives.Link testId="link" href={href}>
|
|
45
|
+
text
|
|
46
|
+
</defaultPrimitives.Link>,
|
|
47
|
+
);
|
|
48
|
+
const node = screen.getByTestId("link");
|
|
49
|
+
expect(node.getAttribute("href")).toBe("#");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("Link-Primitive lässt sichere hrefs (https/mailto/relativ) unverändert", () => {
|
|
53
|
+
for (const href of ["https://example.com", "mailto:a@example.com", "/relative/path"]) {
|
|
54
|
+
render(
|
|
55
|
+
<defaultPrimitives.Link testId={`link-${href}`} href={href}>
|
|
56
|
+
text
|
|
57
|
+
</defaultPrimitives.Link>,
|
|
58
|
+
);
|
|
59
|
+
expect(screen.getByTestId(`link-${href}`).getAttribute("href")).toBe(href);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { CustomScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { firstOpenScreenQn } from "../create-app";
|
|
5
|
+
|
|
6
|
+
function feature(
|
|
7
|
+
overrides: Partial<FeatureSchema> & { readonly featureName: string },
|
|
8
|
+
): FeatureSchema {
|
|
9
|
+
return { entities: {}, screens: [], ...overrides };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function customScreen(
|
|
13
|
+
overrides: Partial<CustomScreenDefinition> & { readonly id: string },
|
|
14
|
+
): CustomScreenDefinition {
|
|
15
|
+
return { type: "custom", renderer: {}, ...overrides };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("firstOpenScreenQn", () => {
|
|
19
|
+
test("picks an open screen that is placed in nav", () => {
|
|
20
|
+
const features: readonly FeatureSchema[] = [
|
|
21
|
+
feature({
|
|
22
|
+
featureName: "shop",
|
|
23
|
+
screens: [customScreen({ id: "catalog" })],
|
|
24
|
+
navs: [{ id: "catalog", label: "shop:nav.catalog", screen: "shop:screen:catalog" }],
|
|
25
|
+
}),
|
|
26
|
+
];
|
|
27
|
+
expect(firstOpenScreenQn(features)).toBe("shop:screen:catalog");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("skips a dormant open screen that has no nav entry (#1258)", () => {
|
|
31
|
+
const features: readonly FeatureSchema[] = [
|
|
32
|
+
feature({
|
|
33
|
+
featureName: "auth-mfa",
|
|
34
|
+
screens: [customScreen({ id: "auth-mfa-enable", access: { openToAll: true } })],
|
|
35
|
+
// No nav entry — this is auth-mfa's dormant custom-screen convention.
|
|
36
|
+
}),
|
|
37
|
+
feature({
|
|
38
|
+
featureName: "shop",
|
|
39
|
+
screens: [customScreen({ id: "catalog" })],
|
|
40
|
+
navs: [{ id: "catalog", label: "shop:nav.catalog", screen: "shop:screen:catalog" }],
|
|
41
|
+
}),
|
|
42
|
+
];
|
|
43
|
+
expect(firstOpenScreenQn(features)).toBe("shop:screen:catalog");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("skips role-restricted screens even when placed in nav", () => {
|
|
47
|
+
const features: readonly FeatureSchema[] = [
|
|
48
|
+
feature({
|
|
49
|
+
featureName: "admin",
|
|
50
|
+
screens: [customScreen({ id: "dashboard", access: { roles: ["Admin"] } })],
|
|
51
|
+
navs: [{ id: "dashboard", label: "admin:nav.dashboard", screen: "admin:screen:dashboard" }],
|
|
52
|
+
}),
|
|
53
|
+
];
|
|
54
|
+
expect(firstOpenScreenQn(features)).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("returns undefined when no screen is both open and nav-placed", () => {
|
|
58
|
+
const features: readonly FeatureSchema[] = [
|
|
59
|
+
feature({
|
|
60
|
+
featureName: "auth-mfa",
|
|
61
|
+
screens: [customScreen({ id: "auth-mfa-enable", access: { openToAll: true } })],
|
|
62
|
+
}),
|
|
63
|
+
];
|
|
64
|
+
expect(firstOpenScreenQn(features)).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("resolves a bare screen id in nav to the qualified screen name", () => {
|
|
68
|
+
const features: readonly FeatureSchema[] = [
|
|
69
|
+
feature({
|
|
70
|
+
featureName: "shop",
|
|
71
|
+
screens: [customScreen({ id: "catalog" })],
|
|
72
|
+
navs: [{ id: "catalog", label: "shop:nav.catalog", screen: "catalog" }],
|
|
73
|
+
}),
|
|
74
|
+
];
|
|
75
|
+
expect(firstOpenScreenQn(features)).toBe("shop:screen:catalog");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -67,7 +67,7 @@ export function createBrowserLocaleResolver(
|
|
|
67
67
|
timeZone: () => timeZone,
|
|
68
68
|
subscribe: store.subscribe,
|
|
69
69
|
setLocale: (next) => {
|
|
70
|
-
//
|
|
70
|
+
// skip: locale unchanged, avoid redundant localStorage write
|
|
71
71
|
// own Object.is-Gate would already block listener notification,
|
|
72
72
|
// but the persistence side-effect lives outside the store.
|
|
73
73
|
if (next === store.getSnapshot()) return;
|
package/src/app/create-app.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ColumnRendererComponent,
|
|
12
12
|
ColumnRenderersProvider,
|
|
13
13
|
CustomScreensProvider,
|
|
14
|
+
DashboardBodyProvider,
|
|
14
15
|
DispatcherProvider,
|
|
15
16
|
type ExtensionSectionComponent,
|
|
16
17
|
ExtensionSectionsProvider,
|
|
@@ -19,13 +20,16 @@ import {
|
|
|
19
20
|
kumikoDefaultTranslations,
|
|
20
21
|
LiveEventsProvider,
|
|
21
22
|
LocaleProvider,
|
|
23
|
+
mergeTranslations,
|
|
22
24
|
type NavApi,
|
|
23
25
|
NavProvider,
|
|
24
26
|
PrimitivesProvider,
|
|
25
27
|
type PrimitivesRegistry,
|
|
26
28
|
qualifyScreenId,
|
|
27
29
|
TokensProvider,
|
|
30
|
+
type TranslationsByLocale,
|
|
28
31
|
toAppSchema,
|
|
32
|
+
translationsByLocaleFromKeys,
|
|
29
33
|
useNav,
|
|
30
34
|
} from "@cosmicdrift/kumiko-renderer";
|
|
31
35
|
import { type ComponentType, type ReactNode, useMemo } from "react";
|
|
@@ -38,6 +42,7 @@ import { useBrowserTokensApi } from "../tokens";
|
|
|
38
42
|
import { UpdateChecker } from "../version/update-checker";
|
|
39
43
|
import { createBrowserLocaleResolver } from "./browser-locale";
|
|
40
44
|
import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
|
|
45
|
+
import { WebDashboardBody } from "./dashboard-body";
|
|
41
46
|
import { useBrowserNavApi } from "./nav";
|
|
42
47
|
import { NavProvidersProvider } from "./nav-providers-context";
|
|
43
48
|
import { type ResolverComponent, ResolversProvider } from "./resolvers-context";
|
|
@@ -124,6 +129,42 @@ function readInjectedSchema(): AppSchema | FeatureSchema | undefined {
|
|
|
124
129
|
return w.__KUMIKO_SCHEMA__;
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
// Erstes Screen über alle Features in deklarierter Reihenfolge, dessen
|
|
133
|
+
// `access` niemanden ausschließt (undefined oder openToAll). Die Landing-
|
|
134
|
+
// Route wird VOR Auth-Resolution gewählt, kennt also keine User-Rollen —
|
|
135
|
+
// ein role-restricted Screen (z.B. bundled user/tenant, SystemAdmin-only)
|
|
136
|
+
// darf hier nie gewinnen, sonst landet jeder Nicht-Admin auf einem
|
|
137
|
+
// Access-Denied-Screen (#1176).
|
|
138
|
+
// Also requires the screen be reachable via r.nav, otherwise a dormant
|
|
139
|
+
// `type: "custom"` screen a feature only registers for manual app-side
|
|
140
|
+
// placement (e.g. auth-mfa's enable screen) can win by declaration order
|
|
141
|
+
// alone, landing every app without an explicit `screenQn` on a screen
|
|
142
|
+
// nobody wired a component for (#1258).
|
|
143
|
+
export function firstOpenScreenQn(features: readonly FeatureSchema[]): string | undefined {
|
|
144
|
+
// NavDefinition.screen carries two shapes in practice: most bundled
|
|
145
|
+
// features author it pre-qualified ("tenant:screen:members"), but the
|
|
146
|
+
// config settings-hub generator emits the bare short id. Index both
|
|
147
|
+
// forms so nav-reachability doesn't depend on which convention a given
|
|
148
|
+
// feature happens to use.
|
|
149
|
+
const navScreenQns = new Set<string>();
|
|
150
|
+
for (const f of features) {
|
|
151
|
+
for (const n of f.navs ?? []) {
|
|
152
|
+
if (n.screen === undefined) continue;
|
|
153
|
+
navScreenQns.add(n.screen);
|
|
154
|
+
navScreenQns.add(qualifyScreenId(f.featureName, n.screen));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const feature of features) {
|
|
158
|
+
const openScreen = feature.screens.find(
|
|
159
|
+
(s) =>
|
|
160
|
+
(s.access === undefined || "openToAll" in s.access) &&
|
|
161
|
+
navScreenQns.has(qualifyScreenId(feature.featureName, s.id)),
|
|
162
|
+
);
|
|
163
|
+
if (openScreen !== undefined) return qualifyScreenId(feature.featureName, openScreen.id);
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
127
168
|
export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonly root: Root } {
|
|
128
169
|
const rootId = options.rootId ?? "root";
|
|
129
170
|
const container = document.getElementById(rootId);
|
|
@@ -149,19 +190,11 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
149
190
|
}
|
|
150
191
|
const app = toAppSchema(rawSchema);
|
|
151
192
|
|
|
152
|
-
// Fallback-Screen
|
|
153
|
-
|
|
154
|
-
// feature with screens.
|
|
155
|
-
const firstFeatureWithScreens = app.features.find((f) => f.screens.length > 0);
|
|
156
|
-
const firstScreen = firstFeatureWithScreens?.screens[0];
|
|
157
|
-
const fallbackQn =
|
|
158
|
-
options.screenQn ??
|
|
159
|
-
(firstScreen !== undefined && firstFeatureWithScreens !== undefined
|
|
160
|
-
? qualifyScreenId(firstFeatureWithScreens.featureName, firstScreen.id)
|
|
161
|
-
: undefined);
|
|
193
|
+
// Fallback-Screen falls kein explizites screenQn übergeben wurde.
|
|
194
|
+
const fallbackQn = options.screenQn ?? firstOpenScreenQn(app.features);
|
|
162
195
|
if (!fallbackQn) {
|
|
163
196
|
throw new Error(
|
|
164
|
-
"createKumikoApp: schema contains no screens. Add at least one entry to `schema.screens` or pass `screenQn` explicitly.",
|
|
197
|
+
"createKumikoApp: schema contains no screens accessible without a role restriction. Add at least one entry to `schema.screens` without `access.roles`, or pass `screenQn` explicitly.",
|
|
165
198
|
);
|
|
166
199
|
}
|
|
167
200
|
|
|
@@ -175,12 +208,24 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
175
208
|
const clientFeatures = options.clientFeatures ?? [];
|
|
176
209
|
const providers = clientFeatures.flatMap((f) => f.providers ?? []);
|
|
177
210
|
const gates = clientFeatures.flatMap((f) => f.gates ?? []);
|
|
178
|
-
//
|
|
179
|
-
// clientFeatures.translations
|
|
180
|
-
//
|
|
181
|
-
//
|
|
211
|
+
// Precedence in fallbackBundles (Array-Order = Priorität, höchste zuerst):
|
|
212
|
+
// 1. clientFeatures.translations — App-Overrides gewinnen immer, auch
|
|
213
|
+
// gegen framework-eigene Labels.
|
|
214
|
+
// 2. schemaTranslations — server-authored r.translations, von
|
|
215
|
+
// buildAppSchema verbatim projiziert (#1059). Ohne dieses Bundle
|
|
216
|
+
// resolven Nav-/Screen-Labels nur, wenn eine App sie ZUSÄTZLICH in
|
|
217
|
+
// web/i18n.ts dupliziert — die meisten bundled-features taten das
|
|
218
|
+
// nie, Labels rendern dann als rohe i18n-Keys.
|
|
219
|
+
// 3. kumikoDefaultTranslations — Framework-Defaults, ALLERLETZTER
|
|
220
|
+
// Fallback.
|
|
221
|
+
const schemaTranslations = app.features.reduce<TranslationsByLocale>(
|
|
222
|
+
(acc, f) =>
|
|
223
|
+
f.translations ? mergeTranslations(acc, translationsByLocaleFromKeys(f.translations)) : acc,
|
|
224
|
+
{},
|
|
225
|
+
);
|
|
182
226
|
const fallbackBundles = [
|
|
183
227
|
...clientFeatures.flatMap((f) => (f.translations !== undefined ? [f.translations] : [])),
|
|
228
|
+
schemaTranslations,
|
|
184
229
|
kumikoDefaultTranslations,
|
|
185
230
|
];
|
|
186
231
|
// Custom-Screen-Components-Map mergen: spätere Features überschreiben
|
|
@@ -285,20 +330,22 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
285
330
|
<PrimitivesProvider value={primitives}>
|
|
286
331
|
<DispatcherProvider dispatcher={dispatcher}>
|
|
287
332
|
<LiveEventsProvider value={liveEvents}>
|
|
288
|
-
<
|
|
289
|
-
<
|
|
290
|
-
<
|
|
291
|
-
<
|
|
292
|
-
<
|
|
293
|
-
<
|
|
294
|
-
<
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
333
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
334
|
+
<CustomScreensProvider value={customScreens}>
|
|
335
|
+
<ColumnRenderersProvider value={columnRenderers}>
|
|
336
|
+
<ExtensionSectionsProvider value={extensionSectionComponents}>
|
|
337
|
+
<NavProvidersProvider value={navProviders} entities={navEntities}>
|
|
338
|
+
<ResolversProvider resolvers={resolvers}>
|
|
339
|
+
<ToastProvider>
|
|
340
|
+
<UpdateChecker />
|
|
341
|
+
{stackWrappers(providers, stackWrappers(gates, screenNode))}
|
|
342
|
+
</ToastProvider>
|
|
343
|
+
</ResolversProvider>
|
|
344
|
+
</NavProvidersProvider>
|
|
345
|
+
</ExtensionSectionsProvider>
|
|
346
|
+
</ColumnRenderersProvider>
|
|
347
|
+
</CustomScreensProvider>
|
|
348
|
+
</DashboardBodyProvider>
|
|
302
349
|
</LiveEventsProvider>
|
|
303
350
|
</DispatcherProvider>
|
|
304
351
|
</PrimitivesProvider>
|
|
@@ -436,6 +483,25 @@ function RoutedScreen({
|
|
|
436
483
|
};
|
|
437
484
|
}, [onRowClick, app.features, nav]);
|
|
438
485
|
|
|
486
|
+
// Copy-Link-Action (Issue #912) für entityEdit-Update-Screens. Baut die
|
|
487
|
+
// absolute Permalink-URL aus der aktuellen Route + kopiert sie —
|
|
488
|
+
// `navigator`/`window` sind hier erlaubt (renderer-web, kein
|
|
489
|
+
// platform-neutrales Package). Kein Button ohne entityId (create-mode).
|
|
490
|
+
// Silent-catch bei Clipboard-Fehler (non-secure context) mirrort das
|
|
491
|
+
// bestehende Muster in pat-tokens-screen.tsx.
|
|
492
|
+
const effectiveOnCopyLink = useMemo<(() => Promise<void> | void) | undefined>(() => {
|
|
493
|
+
const route = nav.route;
|
|
494
|
+
if (route?.entityId === undefined) return undefined;
|
|
495
|
+
return async () => {
|
|
496
|
+
const url = `${window.location.origin}${nav.hrefFor(route)}`;
|
|
497
|
+
try {
|
|
498
|
+
await navigator.clipboard.writeText(url);
|
|
499
|
+
} catch {
|
|
500
|
+
// clipboard blocked (non-secure context) — no fallback UI needed here
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
}, [nav]);
|
|
504
|
+
|
|
439
505
|
// KumikoScreen will nach wie vor ein single-feature schema. Wir
|
|
440
506
|
// füttern es mit dem owning Feature — es enthält Entity-Defs +
|
|
441
507
|
// Screen-Defs für den aktiven Render-Pfad. Kein Owner gefunden → wir
|
|
@@ -455,6 +521,7 @@ function RoutedScreen({
|
|
|
455
521
|
{...(translate !== undefined && { translate })}
|
|
456
522
|
{...(entityId !== undefined && { entityId })}
|
|
457
523
|
onRowClick={effectiveOnRowClick}
|
|
524
|
+
{...(effectiveOnCopyLink !== undefined && { onCopyLink: effectiveOnCopyLink })}
|
|
458
525
|
/>
|
|
459
526
|
);
|
|
460
527
|
}
|