@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.
- package/package.json +63 -0
- package/src/__tests__/avatar.test.tsx +34 -0
- package/src/__tests__/combobox.test.tsx +240 -0
- package/src/__tests__/config-edit.test.tsx +172 -0
- package/src/__tests__/create-app.test.tsx +261 -0
- package/src/__tests__/date-input.test.tsx +91 -0
- package/src/__tests__/default-app-shell.test.tsx +60 -0
- package/src/__tests__/dispatcher-context.test.tsx +101 -0
- package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
- package/src/__tests__/kumiko-screen.test.tsx +1014 -0
- package/src/__tests__/language-switcher.test.tsx +100 -0
- package/src/__tests__/money-input.test.tsx +232 -0
- package/src/__tests__/nav-base-path.test.tsx +388 -0
- package/src/__tests__/nav-search-params.test.tsx +88 -0
- package/src/__tests__/nav-tree.test.tsx +183 -0
- package/src/__tests__/nav.test.tsx +253 -0
- package/src/__tests__/primitives.test.tsx +936 -0
- package/src/__tests__/render-edit.test.tsx +178 -0
- package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
- package/src/__tests__/render-list-debounce.test.tsx +128 -0
- package/src/__tests__/render-list.test.tsx +151 -0
- package/src/__tests__/sidebar.test.tsx +59 -0
- package/src/__tests__/test-utils.tsx +144 -0
- package/src/__tests__/theme-toggle.test.tsx +101 -0
- package/src/__tests__/toast.test.tsx +162 -0
- package/src/__tests__/use-form.test.tsx +112 -0
- package/src/__tests__/use-query-live.test.tsx +152 -0
- package/src/__tests__/use-query.test.tsx +88 -0
- package/src/__tests__/use-store.test.tsx +139 -0
- package/src/__tests__/workspace-shell.test.tsx +772 -0
- package/src/app/browser-locale.ts +85 -0
- package/src/app/client-plugin.tsx +63 -0
- package/src/app/create-app.tsx +380 -0
- package/src/app/nav.tsx +226 -0
- package/src/index.ts +137 -0
- package/src/layout/app-layout.tsx +35 -0
- package/src/layout/avatar.tsx +93 -0
- package/src/layout/default-app-shell.tsx +74 -0
- package/src/layout/language-switcher.tsx +101 -0
- package/src/layout/nav-tree.tsx +281 -0
- package/src/layout/profile-menu.tsx +40 -0
- package/src/layout/sidebar.tsx +65 -0
- package/src/layout/theme-toggle.tsx +44 -0
- package/src/layout/topbar.tsx +22 -0
- package/src/layout/workspace-shell.tsx +282 -0
- package/src/layout/workspace-switcher.tsx +62 -0
- package/src/lib/cn.ts +10 -0
- package/src/primitives/action-menu.tsx +111 -0
- package/src/primitives/combobox.tsx +261 -0
- package/src/primitives/date-input.tsx +165 -0
- package/src/primitives/dialog.tsx +119 -0
- package/src/primitives/dropdown-menu.tsx +103 -0
- package/src/primitives/index.tsx +1271 -0
- package/src/primitives/money-input.tsx +192 -0
- package/src/primitives/toast.tsx +166 -0
- package/src/sse/live-events.ts +90 -0
- package/src/styles.css +113 -0
- 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
|
+
});
|