@cosmicdrift/kumiko-renderer-web 0.1.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.
Files changed (58) hide show
  1. package/package.json +63 -0
  2. package/src/__tests__/avatar.test.tsx +34 -0
  3. package/src/__tests__/combobox.test.tsx +240 -0
  4. package/src/__tests__/config-edit.test.tsx +172 -0
  5. package/src/__tests__/create-app.test.tsx +261 -0
  6. package/src/__tests__/date-input.test.tsx +91 -0
  7. package/src/__tests__/default-app-shell.test.tsx +60 -0
  8. package/src/__tests__/dispatcher-context.test.tsx +101 -0
  9. package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
  10. package/src/__tests__/kumiko-screen.test.tsx +1014 -0
  11. package/src/__tests__/language-switcher.test.tsx +100 -0
  12. package/src/__tests__/money-input.test.tsx +232 -0
  13. package/src/__tests__/nav-base-path.test.tsx +388 -0
  14. package/src/__tests__/nav-search-params.test.tsx +88 -0
  15. package/src/__tests__/nav-tree.test.tsx +183 -0
  16. package/src/__tests__/nav.test.tsx +253 -0
  17. package/src/__tests__/primitives.test.tsx +936 -0
  18. package/src/__tests__/render-edit.test.tsx +178 -0
  19. package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
  20. package/src/__tests__/render-list-debounce.test.tsx +128 -0
  21. package/src/__tests__/render-list.test.tsx +151 -0
  22. package/src/__tests__/sidebar.test.tsx +59 -0
  23. package/src/__tests__/test-utils.tsx +144 -0
  24. package/src/__tests__/theme-toggle.test.tsx +101 -0
  25. package/src/__tests__/toast.test.tsx +162 -0
  26. package/src/__tests__/use-form.test.tsx +112 -0
  27. package/src/__tests__/use-query-live.test.tsx +152 -0
  28. package/src/__tests__/use-query.test.tsx +88 -0
  29. package/src/__tests__/use-store.test.tsx +139 -0
  30. package/src/__tests__/workspace-shell.test.tsx +772 -0
  31. package/src/app/browser-locale.ts +85 -0
  32. package/src/app/client-plugin.tsx +63 -0
  33. package/src/app/create-app.tsx +380 -0
  34. package/src/app/nav.tsx +226 -0
  35. package/src/index.ts +137 -0
  36. package/src/layout/app-layout.tsx +35 -0
  37. package/src/layout/avatar.tsx +93 -0
  38. package/src/layout/default-app-shell.tsx +74 -0
  39. package/src/layout/language-switcher.tsx +101 -0
  40. package/src/layout/nav-tree.tsx +281 -0
  41. package/src/layout/profile-menu.tsx +40 -0
  42. package/src/layout/sidebar.tsx +65 -0
  43. package/src/layout/theme-toggle.tsx +44 -0
  44. package/src/layout/topbar.tsx +22 -0
  45. package/src/layout/workspace-shell.tsx +282 -0
  46. package/src/layout/workspace-switcher.tsx +62 -0
  47. package/src/lib/cn.ts +10 -0
  48. package/src/primitives/action-menu.tsx +111 -0
  49. package/src/primitives/combobox.tsx +261 -0
  50. package/src/primitives/date-input.tsx +165 -0
  51. package/src/primitives/dialog.tsx +119 -0
  52. package/src/primitives/dropdown-menu.tsx +103 -0
  53. package/src/primitives/index.tsx +1271 -0
  54. package/src/primitives/money-input.tsx +192 -0
  55. package/src/primitives/toast.tsx +166 -0
  56. package/src/sse/live-events.ts +90 -0
  57. package/src/styles.css +113 -0
  58. package/src/tokens.ts +63 -0
@@ -0,0 +1,178 @@
1
+ // @vitest-environment jsdom
2
+ import type {
3
+ EntityDefinition,
4
+ EntityEditScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import type { Dispatcher, SubmitResult } from "@cosmicdrift/kumiko-headless";
7
+ import { DispatcherProvider, RenderEdit } from "@cosmicdrift/kumiko-renderer";
8
+ import { describe, expect, test, vi } from "vitest";
9
+ import { act, createMockDispatcher, fireEvent, render, screen } from "./test-utils";
10
+
11
+ const orderEntity = {
12
+ fields: {
13
+ title: { type: "text", required: true },
14
+ count: { type: "number" },
15
+ isUrgent: { type: "boolean" },
16
+ notes: { type: "text" },
17
+ },
18
+ } as unknown as EntityDefinition;
19
+
20
+ function makeScreen(): EntityEditScreenDefinition {
21
+ return {
22
+ id: "orders:screen:order-edit",
23
+ type: "entityEdit",
24
+ entity: "order",
25
+ layout: {
26
+ sections: [
27
+ {
28
+ title: "Basics",
29
+ columns: 2,
30
+ fields: [
31
+ { field: "title", span: 2 },
32
+ "count",
33
+ "isUrgent",
34
+ {
35
+ field: "notes",
36
+ visible: (d) => (d as { isUrgent?: boolean }).isUrgent === true,
37
+ required: (d) => (d as { isUrgent?: boolean }).isUrgent === true,
38
+ },
39
+ ],
40
+ },
41
+ ],
42
+ },
43
+ };
44
+ }
45
+
46
+ function makeDispatcher(writeFn?: Dispatcher["write"]): Dispatcher {
47
+ return createMockDispatcher({
48
+ write:
49
+ writeFn ?? ((async () => ({ isSuccess: true, data: { id: "1" } })) as Dispatcher["write"]),
50
+ });
51
+ }
52
+
53
+ type TestValues = {
54
+ title: string;
55
+ count?: number;
56
+ isUrgent?: boolean;
57
+ notes?: string;
58
+ };
59
+
60
+ describe("RenderEdit", () => {
61
+ test("renders a field per visible section entry with its resolved label", () => {
62
+ const dispatcher = makeDispatcher();
63
+ render(
64
+ <DispatcherProvider dispatcher={dispatcher}>
65
+ <RenderEdit<TestValues>
66
+ screen={makeScreen()}
67
+ entity={orderEntity}
68
+ featureName="orders"
69
+ initial={{ title: "", count: 0, isUrgent: false }}
70
+ writeCommand="order:create"
71
+ />
72
+ </DispatcherProvider>,
73
+ );
74
+
75
+ // Visible: title, count, isUrgent. notes hidden because isUrgent=false.
76
+ expect(screen.getByTestId("field-title")).toBeTruthy();
77
+ expect(screen.getByTestId("field-count")).toBeTruthy();
78
+ expect(screen.getByTestId("field-isUrgent")).toBeTruthy();
79
+ expect(screen.queryByTestId("field-notes")).toBeNull();
80
+ });
81
+
82
+ test("typing in an input updates the form snapshot (controller + view-model round-trip)", () => {
83
+ render(
84
+ <DispatcherProvider dispatcher={makeDispatcher()}>
85
+ <RenderEdit<TestValues>
86
+ screen={makeScreen()}
87
+ entity={orderEntity}
88
+ featureName="orders"
89
+ initial={{ title: "", count: 0, isUrgent: false }}
90
+ writeCommand="order:create"
91
+ />
92
+ </DispatcherProvider>,
93
+ );
94
+
95
+ const titleInput = screen.getByTestId("field-title").querySelector("input");
96
+ expect(titleInput).toBeTruthy();
97
+ fireEvent.change(titleInput as HTMLInputElement, { target: { value: "Acme" } });
98
+ expect((titleInput as HTMLInputElement).value).toBe("Acme");
99
+ });
100
+
101
+ test("toggling isUrgent reveals the notes field (conditional predicate re-evaluates)", () => {
102
+ render(
103
+ <DispatcherProvider dispatcher={makeDispatcher()}>
104
+ <RenderEdit<TestValues>
105
+ screen={makeScreen()}
106
+ entity={orderEntity}
107
+ featureName="orders"
108
+ initial={{ title: "", count: 0, isUrgent: false }}
109
+ writeCommand="order:create"
110
+ />
111
+ </DispatcherProvider>,
112
+ );
113
+
114
+ expect(screen.queryByTestId("field-notes")).toBeNull();
115
+ const urgentCheckbox = screen
116
+ .getByTestId("field-isUrgent")
117
+ .querySelector("input[type=checkbox]");
118
+ fireEvent.click(urgentCheckbox as HTMLInputElement);
119
+ expect(screen.queryByTestId("field-notes")).toBeTruthy();
120
+ });
121
+
122
+ test("submit fires dispatcher.write with the current values; onSubmit receives the result", async () => {
123
+ const write = vi.fn(async () => ({ isSuccess: true, data: { id: "42" } }) as never);
124
+ const dispatcher = makeDispatcher(write);
125
+ const seenResults: SubmitResult<unknown>[] = [];
126
+
127
+ render(
128
+ <DispatcherProvider dispatcher={dispatcher}>
129
+ <RenderEdit<TestValues>
130
+ screen={makeScreen()}
131
+ entity={orderEntity}
132
+ featureName="orders"
133
+ initial={{ title: "", count: 0, isUrgent: false }}
134
+ writeCommand="order:create"
135
+ onSubmit={(r) => seenResults.push(r)}
136
+ />
137
+ </DispatcherProvider>,
138
+ );
139
+
140
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
141
+ fireEvent.change(titleInput, { target: { value: "Hello" } });
142
+
143
+ const form = screen.getByTestId("render-edit-form");
144
+ // `act` so the async state update React does after submit resolves
145
+ // (flipping isDirty back to false after rebase) is flushed before
146
+ // the assertions run.
147
+ await act(async () => {
148
+ fireEvent.submit(form);
149
+ // microtask boundary for the handleSubmit promise chain
150
+ await Promise.resolve();
151
+ });
152
+
153
+ expect(write).toHaveBeenCalledOnce();
154
+ expect(write).toHaveBeenCalledWith("order:create", expect.anything());
155
+ expect(seenResults).toHaveLength(1);
156
+ expect(seenResults[0]?.isSuccess).toBe(true);
157
+ });
158
+
159
+ test("title resolved aus i18n-Key `screen:<id>.title` mit screenId als Fallback", () => {
160
+ const dispatcher = makeDispatcher();
161
+ render(
162
+ <DispatcherProvider dispatcher={dispatcher}>
163
+ <RenderEdit<TestValues>
164
+ screen={makeScreen()}
165
+ entity={orderEntity}
166
+ featureName="orders"
167
+ initial={{ title: "", count: 0, isUrgent: false }}
168
+ writeCommand="order:create"
169
+ />
170
+ </DispatcherProvider>,
171
+ );
172
+ // Default-Translate (Test-Setup hat keinen Bundle für screen:*.title)
173
+ // → i18n returnt den Key selber, RenderEdit detected das + zeigt
174
+ // den screenId. Beweist die Convention: kein Hardcoded "Untitled".
175
+ const actionsBar = screen.getByTestId("render-edit-form-actions");
176
+ expect(actionsBar.textContent).toContain("orders:screen:order-edit");
177
+ });
178
+ });
@@ -0,0 +1,124 @@
1
+ // @vitest-environment jsdom
2
+ import type {
3
+ EntityDefinition,
4
+ EntityListScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import {
7
+ type ColumnRendererProps,
8
+ ColumnRenderersProvider,
9
+ RenderList,
10
+ } from "@cosmicdrift/kumiko-renderer";
11
+ import type { ReactElement, ReactNode } from "react";
12
+ import { afterAll, beforeEach, describe, expect, type MockInstance, test, vi } from "vitest";
13
+ import { render, screen } from "./test-utils";
14
+
15
+ // Tests für die JSX-Renderer-Form von ListColumn-Spalten:
16
+ // `{ react: { __component: "Name" } }` wird via ColumnRenderersProvider
17
+ // auf eine echte React-Component aufgelöst. Function- und Default-Pfad
18
+ // sind in render-list.test.tsx abgedeckt — hier geht es um die drei
19
+ // Cases die mit dem Provider-Lookup zusammenhängen.
20
+
21
+ const taskEntity = {
22
+ fields: {
23
+ title: { type: "text" },
24
+ color: { type: "text" },
25
+ },
26
+ } as unknown as EntityDefinition;
27
+
28
+ const baseScreen: EntityListScreenDefinition = {
29
+ id: "tasks:screen:task-list",
30
+ type: "entityList",
31
+ entity: "task",
32
+ columns: ["title", "color"],
33
+ };
34
+
35
+ function ColorSwatch({ value, row, column }: ColumnRendererProps): ReactNode {
36
+ return (
37
+ <span data-testid="swatch">
38
+ <span data-testid="swatch-value">{String(value)}</span>
39
+ <span data-testid="swatch-field">{column.field}</span>
40
+ <span data-testid="swatch-row-title">{String(row["title"] ?? "")}</span>
41
+ </span>
42
+ );
43
+ }
44
+
45
+ function withRenderers(ui: ReactNode, map: Record<string, typeof ColorSwatch>): ReactElement {
46
+ return <ColumnRenderersProvider value={map}>{ui}</ColumnRenderersProvider>;
47
+ }
48
+
49
+ describe("RenderList — column-renderer registry", () => {
50
+ // Spy lokal pro Test installieren + global zurückbauen, damit die
51
+ // Mock-Implementation nicht in andere Test-Dateien leakt (Console-Spy
52
+ // auf File-Level würde den ganzen Vitest-Worker betreffen).
53
+ let warnSpy: MockInstance<typeof console.warn>;
54
+ beforeEach(() => {
55
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
56
+ });
57
+ afterAll(() => {
58
+ warnSpy.mockRestore();
59
+ });
60
+
61
+ test("function-renderer pfad bleibt unverändert (Bestand)", () => {
62
+ const screenWithFn: EntityListScreenDefinition = {
63
+ ...baseScreen,
64
+ columns: ["title", { field: "color", renderer: (v) => `[${String(v)}]` }],
65
+ };
66
+ render(
67
+ <RenderList
68
+ screen={screenWithFn}
69
+ entity={taskEntity}
70
+ rows={[{ id: "r1", title: "Alpha", color: "#fff" }]}
71
+ featureName="tasks"
72
+ />,
73
+ );
74
+ expect(screen.getByTestId("cell-r1-color").textContent).toBe("[#fff]");
75
+ });
76
+
77
+ test("__component-renderer mit Provider → Component wird gemountet, value+row+column kommen an", () => {
78
+ const screenWithComp: EntityListScreenDefinition = {
79
+ ...baseScreen,
80
+ columns: ["title", { field: "color", renderer: { react: { __component: "ColorSwatch" } } }],
81
+ };
82
+ render(
83
+ withRenderers(
84
+ <RenderList
85
+ screen={screenWithComp}
86
+ entity={taskEntity}
87
+ rows={[{ id: "r1", title: "Alpha", color: "#abcdef" }]}
88
+ featureName="tasks"
89
+ />,
90
+ { ColorSwatch },
91
+ ),
92
+ );
93
+ expect(screen.getByTestId("swatch")).toBeTruthy();
94
+ expect(screen.getByTestId("swatch-value").textContent).toBe("#abcdef");
95
+ expect(screen.getByTestId("swatch-field").textContent).toBe("color");
96
+ expect(screen.getByTestId("swatch-row-title").textContent).toBe("Alpha");
97
+ });
98
+
99
+ test("__component-renderer ohne Registry-Eintrag → console.warn + Default-Fallback", () => {
100
+ const screenWithUnknown: EntityListScreenDefinition = {
101
+ ...baseScreen,
102
+ columns: [
103
+ "title",
104
+ { field: "color", renderer: { react: { __component: "MissingRenderer" } } },
105
+ ],
106
+ };
107
+ render(
108
+ withRenderers(
109
+ <RenderList
110
+ screen={screenWithUnknown}
111
+ entity={taskEntity}
112
+ rows={[{ id: "r1", title: "Alpha", color: "#abc" }]}
113
+ featureName="tasks"
114
+ />,
115
+ {},
116
+ ),
117
+ );
118
+ // Default-Renderer für type=text → roher Wert
119
+ expect(screen.getByTestId("cell-r1-color").textContent).toBe("#abc");
120
+ expect(warnSpy).toHaveBeenCalledWith(
121
+ expect.stringContaining('columnRenderer "MissingRenderer" not registered'),
122
+ );
123
+ });
124
+ });
@@ -0,0 +1,128 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // RenderList puffert Tipps im Search-Input lokal und schickt
4
+ // onSearchChange erst nach 300ms ohne weitere Tasten. Vor dieser Suite
5
+ // nur durch Code-Read bewiesen — bei der nächsten Refactor-Welle wäre
6
+ // die Race-Condition (Sync-Effect auf searchValue + Debounce-Effect)
7
+ // wahrscheinlich kaputt gegangen ohne dass eine CI das fängt.
8
+
9
+ import type {
10
+ EntityDefinition,
11
+ EntityListScreenDefinition,
12
+ } from "@cosmicdrift/kumiko-framework/ui-types";
13
+ import {
14
+ createStaticLocaleResolver,
15
+ LocaleProvider,
16
+ PrimitivesProvider,
17
+ RenderList,
18
+ } from "@cosmicdrift/kumiko-renderer";
19
+ import { act, fireEvent, render, screen } from "@testing-library/react";
20
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
21
+ import { defaultPrimitives } from "../primitives";
22
+
23
+ // Minimal-Entity damit RenderList nicht über fehlende Felder stolpert.
24
+ // Eine Spalte reicht — wir testen nur den Search-Debounce-Pfad, nicht
25
+ // die DataTable-Render-Tiefe.
26
+ const entity: EntityDefinition = {
27
+ fields: { title: { type: "text" } },
28
+ } as EntityDefinition;
29
+
30
+ const screenDef: EntityListScreenDefinition = {
31
+ id: "items",
32
+ type: "entityList",
33
+ entity: "item",
34
+ columns: ["title"],
35
+ };
36
+
37
+ function renderRL(props: {
38
+ readonly searchValue?: string;
39
+ readonly onSearchChange?: (next: string) => void;
40
+ }) {
41
+ return render(
42
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de" })}>
43
+ <PrimitivesProvider value={defaultPrimitives}>
44
+ <RenderList
45
+ screen={screenDef}
46
+ entity={entity}
47
+ rows={[]}
48
+ featureName="t"
49
+ searchable
50
+ {...props}
51
+ />
52
+ </PrimitivesProvider>
53
+ </LocaleProvider>,
54
+ );
55
+ }
56
+
57
+ describe("RenderList — Search-Debounce", () => {
58
+ beforeEach(() => {
59
+ vi.useFakeTimers({ shouldAdvanceTime: true });
60
+ });
61
+
62
+ afterEach(() => {
63
+ vi.useRealTimers();
64
+ });
65
+
66
+ test("Tippen unter 300ms feuert NICHT mehrfach onSearchChange", () => {
67
+ const onSearchChange = vi.fn();
68
+ renderRL({ searchValue: "", onSearchChange });
69
+
70
+ const input = screen.getByPlaceholderText(/kumiko\.list\.search-placeholder|suchen/i);
71
+ fireEvent.change(input, { target: { value: "a" } });
72
+ fireEvent.change(input, { target: { value: "ac" } });
73
+ fireEvent.change(input, { target: { value: "acm" } });
74
+ fireEvent.change(input, { target: { value: "acme" } });
75
+
76
+ // Vor Debounce-Ablauf: kein call (jeder Keypress hat den Timer
77
+ // resettet).
78
+ act(() => {
79
+ vi.advanceTimersByTime(299);
80
+ });
81
+ expect(onSearchChange).not.toHaveBeenCalled();
82
+
83
+ // 300ms: jetzt feuert es exakt einmal mit dem letzten Wert.
84
+ act(() => {
85
+ vi.advanceTimersByTime(1);
86
+ });
87
+ expect(onSearchChange).toHaveBeenCalledTimes(1);
88
+ expect(onSearchChange).toHaveBeenCalledWith("acme");
89
+ });
90
+
91
+ test("searchValue-Update von außen syncs lokalen Buffer (Browser-Back)", () => {
92
+ const onSearchChange = vi.fn();
93
+ const { rerender } = renderRL({ searchValue: "first", onSearchChange });
94
+ const input = screen.getByDisplayValue("first") as HTMLInputElement;
95
+ expect(input.value).toBe("first");
96
+
97
+ // Externe Quelle (Browser-Back, Cross-Component-Reset) ändert
98
+ // searchValue → RenderList soll den Input-Wert spiegeln.
99
+ rerender(
100
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "de" })}>
101
+ <PrimitivesProvider value={defaultPrimitives}>
102
+ <RenderList
103
+ screen={screenDef}
104
+ entity={entity}
105
+ rows={[]}
106
+ featureName="t"
107
+ searchable
108
+ searchValue="second"
109
+ onSearchChange={onSearchChange}
110
+ />
111
+ </PrimitivesProvider>
112
+ </LocaleProvider>,
113
+ );
114
+ expect((screen.getByDisplayValue("second") as HTMLInputElement).value).toBe("second");
115
+ });
116
+
117
+ test("onSearchChange wird NICHT gerufen wenn lokal === searchValue (kein Echo)", () => {
118
+ // Wenn der Parent searchValue auf "x" setzt UND der lokale Buffer
119
+ // schon "x" ist (z.B. Cleanup-Timing), darf RenderList nicht den
120
+ // Wert nochmal zurückrufen — sonst wäre's eine Loop.
121
+ const onSearchChange = vi.fn();
122
+ renderRL({ searchValue: "x", onSearchChange });
123
+ act(() => {
124
+ vi.advanceTimersByTime(500);
125
+ });
126
+ expect(onSearchChange).not.toHaveBeenCalled();
127
+ });
128
+ });
@@ -0,0 +1,151 @@
1
+ // @vitest-environment jsdom
2
+ import type {
3
+ EntityDefinition,
4
+ EntityListScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import { RenderList } from "@cosmicdrift/kumiko-renderer";
7
+ import { describe, expect, test, vi } from "vitest";
8
+ import { fireEvent, render, screen } from "./test-utils";
9
+
10
+ const taskEntity = {
11
+ fields: {
12
+ title: { type: "text", sortable: true },
13
+ status: { type: "text" },
14
+ isUrgent: { type: "boolean" },
15
+ priority: { type: "number" },
16
+ },
17
+ } as unknown as EntityDefinition;
18
+
19
+ const listScreen: EntityListScreenDefinition = {
20
+ id: "tasks:screen:task-list",
21
+ type: "entityList",
22
+ entity: "task",
23
+ columns: [
24
+ "title",
25
+ "status",
26
+ "isUrgent",
27
+ { field: "priority", renderer: (v: unknown) => `P${v}` },
28
+ ],
29
+ };
30
+
31
+ describe("RenderList", () => {
32
+ test("empty state when rows is empty", () => {
33
+ render(<RenderList screen={listScreen} entity={taskEntity} rows={[]} featureName="tasks" />);
34
+ // Default-Primitive hängt "-empty" an die testId der DataTable.
35
+ expect(screen.getByTestId("render-list-table-empty")).toBeTruthy();
36
+ expect(screen.queryByTestId("render-list-table")).toBeNull();
37
+ });
38
+
39
+ test("custom emptyState overrides the default message", () => {
40
+ render(
41
+ <RenderList
42
+ screen={listScreen}
43
+ entity={taskEntity}
44
+ rows={[]}
45
+ featureName="tasks"
46
+ emptyState={<span data-testid="custom-empty">nix da</span>}
47
+ />,
48
+ );
49
+ expect(screen.getByTestId("custom-empty")).toBeTruthy();
50
+ });
51
+
52
+ test("renders a thead with one <th> per column, labeled via translate", () => {
53
+ render(
54
+ <RenderList
55
+ screen={listScreen}
56
+ entity={taskEntity}
57
+ rows={[]}
58
+ featureName="tasks"
59
+ translate={(key) => `T(${key})`}
60
+ emptyState={<span />}
61
+ />,
62
+ );
63
+ // Empty-state branch short-circuits before thead; push a row in.
64
+ render(
65
+ <RenderList
66
+ screen={listScreen}
67
+ entity={taskEntity}
68
+ rows={[{ id: "1", title: "Foo", status: "open", isUrgent: false, priority: 3 }]}
69
+ featureName="tasks"
70
+ translate={(key) => `T(${key})`}
71
+ />,
72
+ );
73
+ expect(screen.getByTestId("column-title").textContent).toBe("T(tasks:entity:task:field:title)");
74
+ expect(screen.getByTestId("column-priority").textContent).toBe(
75
+ "T(tasks:entity:task:field:priority)",
76
+ );
77
+ });
78
+
79
+ test("sortable column gets data-sortable attribute; non-sortable does not", () => {
80
+ render(
81
+ <RenderList
82
+ screen={listScreen}
83
+ entity={taskEntity}
84
+ rows={[{ id: "1", title: "Foo", status: "open", isUrgent: false, priority: 3 }]}
85
+ featureName="tasks"
86
+ />,
87
+ );
88
+ expect(screen.getByTestId("column-title").getAttribute("data-sortable")).toBe("true");
89
+ expect(screen.getByTestId("column-status").getAttribute("data-sortable")).toBeNull();
90
+ });
91
+
92
+ test("renders one row per item, one cell per column, with formatted values", () => {
93
+ render(
94
+ <RenderList
95
+ screen={listScreen}
96
+ entity={taskEntity}
97
+ rows={[
98
+ { id: "r1", title: "Alpha", status: "open", isUrgent: true, priority: 3 },
99
+ { id: "r2", title: "Beta", status: "done", isUrgent: false, priority: 1 },
100
+ ]}
101
+ featureName="tasks"
102
+ />,
103
+ );
104
+
105
+ // Row 1
106
+ expect(screen.getByTestId("cell-r1-title").textContent).toBe("Alpha");
107
+ expect(screen.getByTestId("cell-r1-isUrgent").textContent).toBe("✓");
108
+ expect(screen.getByTestId("cell-r1-priority").textContent).toBe("P3"); // custom renderer
109
+
110
+ // Row 2
111
+ expect(screen.getByTestId("cell-r2-isUrgent").textContent).toBe(""); // false → empty
112
+ expect(screen.getByTestId("cell-r2-priority").textContent).toBe("P1");
113
+ });
114
+
115
+ test("onRowClick fires with the ListRowViewModel when present; no-op without", () => {
116
+ const onClick = vi.fn();
117
+ render(
118
+ <RenderList
119
+ screen={listScreen}
120
+ entity={taskEntity}
121
+ rows={[{ id: "r1", title: "A", status: "open", isUrgent: false, priority: 0 }]}
122
+ featureName="tasks"
123
+ onRowClick={onClick}
124
+ />,
125
+ );
126
+ fireEvent.click(screen.getByTestId("row-r1"));
127
+ expect(onClick).toHaveBeenCalledOnce();
128
+ const arg = onClick.mock.lastCall?.[0] as { id: string; values: Record<string, unknown> };
129
+ expect(arg.id).toBe("r1");
130
+ expect(arg.values["title"]).toBe("A");
131
+ });
132
+
133
+ test("throws on unknown field in a column — boot-validator miss should fail loud", () => {
134
+ const badScreen: EntityListScreenDefinition = {
135
+ id: "tasks:screen:bad-list",
136
+ type: "entityList",
137
+ entity: "task",
138
+ columns: ["ghost"],
139
+ };
140
+ expect(() =>
141
+ render(
142
+ <RenderList
143
+ screen={badScreen}
144
+ entity={taskEntity}
145
+ rows={[{ id: "r1", ghost: "x" }]}
146
+ featureName="tasks"
147
+ />,
148
+ ),
149
+ ).toThrow(/unknown field "ghost"/);
150
+ });
151
+ });
@@ -0,0 +1,59 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Sidebar: 4-Slot-Layout (header, actions, children, footer). Pinnt
4
+ // dass die Sektionen conditional rendern UND in der richtigen
5
+ // Reihenfolge stehen — header → actions → nav → footer.
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { Sidebar } from "../layout/sidebar";
9
+ import { render, screen } from "./test-utils";
10
+
11
+ describe("Sidebar", () => {
12
+ test("alle 4 Slots gesetzt — rendern in Header → Actions → Nav → Footer Reihenfolge", () => {
13
+ render(
14
+ <Sidebar
15
+ header={<span data-testid="h">brand</span>}
16
+ actions={<span data-testid="a">icons</span>}
17
+ footer={<span data-testid="f">profile</span>}
18
+ testId="sidebar"
19
+ >
20
+ <span data-testid="n">nav-content</span>
21
+ </Sidebar>,
22
+ );
23
+ const sidebar = screen.getByTestId("sidebar");
24
+ const header = sidebar.querySelector('[data-kumiko-layout="sidebar-header"]');
25
+ const actions = sidebar.querySelector('[data-kumiko-layout="sidebar-actions"]');
26
+ const nav = sidebar.querySelector('[data-kumiko-layout="sidebar-nav"]');
27
+ const footer = sidebar.querySelector('[data-kumiko-layout="sidebar-footer"]');
28
+
29
+ expect(header).not.toBeNull();
30
+ expect(actions).not.toBeNull();
31
+ expect(nav).not.toBeNull();
32
+ expect(footer).not.toBeNull();
33
+
34
+ // Reihenfolge im DOM: header < actions < nav < footer
35
+ const children = Array.from(sidebar.children);
36
+ expect(children.indexOf(header as Element)).toBeLessThan(children.indexOf(actions as Element));
37
+ expect(children.indexOf(actions as Element)).toBeLessThan(children.indexOf(nav as Element));
38
+ expect(children.indexOf(nav as Element)).toBeLessThan(children.indexOf(footer as Element));
39
+
40
+ // Inhalte landen im richtigen Slot
41
+ expect(header?.contains(screen.getByTestId("h"))).toBe(true);
42
+ expect(actions?.contains(screen.getByTestId("a"))).toBe(true);
43
+ expect(nav?.contains(screen.getByTestId("n"))).toBe(true);
44
+ expect(footer?.contains(screen.getByTestId("f"))).toBe(true);
45
+ });
46
+
47
+ test("nur children gesetzt — Header/Actions/Footer nicht gerendert", () => {
48
+ render(
49
+ <Sidebar testId="sidebar">
50
+ <span data-testid="n">nav</span>
51
+ </Sidebar>,
52
+ );
53
+ const sidebar = screen.getByTestId("sidebar");
54
+ expect(sidebar.querySelector('[data-kumiko-layout="sidebar-header"]')).toBeNull();
55
+ expect(sidebar.querySelector('[data-kumiko-layout="sidebar-actions"]')).toBeNull();
56
+ expect(sidebar.querySelector('[data-kumiko-layout="sidebar-footer"]')).toBeNull();
57
+ expect(sidebar.querySelector('[data-kumiko-layout="sidebar-nav"]')).not.toBeNull();
58
+ });
59
+ });