@cosmicdrift/kumiko-renderer-web 0.149.1 → 0.150.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-web",
3
- "version": "0.149.1",
3
+ "version": "0.150.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.149.1",
20
- "@cosmicdrift/kumiko-headless": "0.149.1",
21
- "@cosmicdrift/kumiko-renderer": "0.149.1",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.150.0",
20
+ "@cosmicdrift/kumiko-headless": "0.150.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.150.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -0,0 +1,101 @@
1
+ // Unit-Tests für den projectionDetail-Screen-Type (read-only single-row
2
+ // inspector, kumiko-framework#255). Deckt den Pfad ab, der Integration/E2E
3
+ // nicht prüft (e2e-generator skippt projectionDetail explizit — kein CRUD):
4
+ // - Row wird über idParam gefetcht, Felder zeigen die Query-Response-Werte
5
+ // - jedes Feld ist readOnly, kein Submit-Button (hasEditableSection=false)
6
+ // - fehlende entityId → Error-Banner statt Crash
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
10
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
11
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
12
+ import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
13
+ import { act, createMockDispatcher, render, screen, waitFor } from "./test-utils";
14
+
15
+ const detailScreen: ProjectionDetailScreenDefinition = {
16
+ id: "session-detail",
17
+ type: "projectionDetail",
18
+ query: "sessions:query:user-session:detail",
19
+ idParam: "id",
20
+ layout: {
21
+ sections: [{ title: "Session", fields: ["userId", "createdAt"] }],
22
+ },
23
+ fieldLabels: {
24
+ userId: "sessions.detail.field.userId",
25
+ createdAt: "sessions.detail.field.createdAt",
26
+ },
27
+ };
28
+
29
+ const schema: FeatureSchema = {
30
+ featureName: "sessions",
31
+ entities: {},
32
+ screens: [detailScreen],
33
+ };
34
+
35
+ describe("KumikoScreen / projectionDetail", () => {
36
+ test("fetches the row via idParam and renders its fields read-only, no submit button", async () => {
37
+ const querySpy = (async (_qn: string, payload: unknown) => {
38
+ expect(payload).toEqual({ id: "sess-1" });
39
+ return {
40
+ isSuccess: true,
41
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
42
+ };
43
+ }) as unknown as Dispatcher["query"];
44
+ const dispatcher: Dispatcher = createMockDispatcher({ query: querySpy });
45
+
46
+ render(
47
+ <DispatcherProvider dispatcher={dispatcher}>
48
+ <KumikoScreen schema={schema} qn="sessions:screen:session-detail" entityId="sess-1" />
49
+ </DispatcherProvider>,
50
+ );
51
+
52
+ await waitFor(() => screen.getByTestId("render-edit-form"));
53
+ const userIdInput = screen.getByTestId("field-userId").querySelector("input");
54
+ expect(userIdInput?.value).toBe("user-42");
55
+ expect(userIdInput?.disabled).toBe(true);
56
+
57
+ // hasEditableSection() reads readOnly on every field — projectionDetail
58
+ // forces it hard in the shim, so RenderEdit must never draw a Save button.
59
+ expect(screen.queryByTestId("render-edit-submit")).toBeNull();
60
+ });
61
+
62
+ test("missing entityId shows an error banner instead of crashing", async () => {
63
+ let resolveQuery: (value: unknown) => void = () => {};
64
+ const dispatcher: Dispatcher = createMockDispatcher({
65
+ query: (() =>
66
+ new Promise((resolve) => {
67
+ resolveQuery = resolve;
68
+ })) as unknown as Dispatcher["query"],
69
+ });
70
+
71
+ render(
72
+ <DispatcherProvider dispatcher={dispatcher}>
73
+ <KumikoScreen schema={schema} qn="sessions:screen:session-detail" />
74
+ </DispatcherProvider>,
75
+ );
76
+
77
+ expect(screen.getByTestId("kumiko-screen-projection-detail-missing-id")).toBeTruthy();
78
+
79
+ // Screen skips the record entirely without entityId, but useQuery's
80
+ // effect still fired (unconditional hook call) — settle it so its async
81
+ // setState doesn't land after the test unmounts.
82
+ await act(async () => {
83
+ resolveQuery({ isSuccess: true, data: {} });
84
+ await Promise.resolve();
85
+ });
86
+ });
87
+
88
+ test("record not found shows an error banner", async () => {
89
+ const dispatcher: Dispatcher = createMockDispatcher({
90
+ query: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["query"],
91
+ });
92
+
93
+ render(
94
+ <DispatcherProvider dispatcher={dispatcher}>
95
+ <KumikoScreen schema={schema} qn="sessions:screen:session-detail" entityId="sess-missing" />
96
+ </DispatcherProvider>,
97
+ );
98
+
99
+ await waitFor(() => screen.getByTestId("kumiko-screen-record-missing"));
100
+ });
101
+ });
@@ -834,4 +834,40 @@ describe("WorkspaceShell — EINE Nav", () => {
834
834
  // Die screen-children sind ersetzt, nicht zusätzlich gerendert.
835
835
  expect(screen.queryByText("content")).toBeNull();
836
836
  });
837
+
838
+ test("navBadges pass through to NavTree leaf items", () => {
839
+ const schema = {
840
+ featureName: "bmc",
841
+ entities: {},
842
+ screens: [],
843
+ navs: [
844
+ { id: "system", label: "System", screen: "bmc:screen:system" },
845
+ { id: "orders", label: "Orders", screen: "bmc:screen:orders" },
846
+ ],
847
+ workspaces: [
848
+ ws("admin", {
849
+ label: "Admin",
850
+ roles: ["admin"],
851
+ order: 1,
852
+ isDefault: true,
853
+ navMembers: ["bmc:nav:system", "bmc:nav:orders"],
854
+ }),
855
+ ],
856
+ } as const;
857
+
858
+ renderShell(
859
+ <WorkspaceShell
860
+ brand={<div>Brand</div>}
861
+ schema={schema}
862
+ user={{ id: "u1", roles: ["admin"] }}
863
+ navBadges={new Map([["orders", <span>3</span>]])}
864
+ >
865
+ <div>content</div>
866
+ </WorkspaceShell>,
867
+ );
868
+ const ordersItems = screen.getAllByText("Orders");
869
+ const systemItems = screen.getAllByText("System");
870
+ expect(ordersItems.some((el) => el.closest("li")?.textContent?.includes("3"))).toBe(true);
871
+ expect(systemItems.every((el) => !el.closest("li")?.textContent?.includes("3"))).toBe(true);
872
+ });
837
873
  });
@@ -75,6 +75,8 @@ export type WorkspaceShellProps = {
75
75
  * Info. Klebt am unteren Rand via `mt-auto` (siehe Sidebar.footer).
76
76
  * Symmetrisch zu DefaultAppShell.sidebarFooter. */
77
77
  readonly sidebarFooter?: ReactNode;
78
+ /** Runtime badge slot per nav leaf (bare nav id), same as DefaultAppShell. */
79
+ readonly navBadges?: ReadonlyMap<string, ReactNode>;
78
80
  /** Screen content. */
79
81
  readonly children: ReactNode;
80
82
  };
@@ -86,6 +88,7 @@ export function WorkspaceShell({
86
88
  user,
87
89
  initialWorkspaceId,
88
90
  sidebarFooter,
91
+ navBadges,
89
92
  children,
90
93
  }: WorkspaceShellProps): ReactNode {
91
94
  const app = useMemo(() => toAppSchema(schema), [schema]);
@@ -207,6 +210,7 @@ export function WorkspaceShell({
207
210
  schema={app}
208
211
  {...(user !== undefined && { user })}
209
212
  {...(allowedNavQns !== undefined && { allowedNavQns })}
213
+ {...(navBadges !== undefined && { navBadges })}
210
214
  />
211
215
  );
212
216