@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,88 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import type { Dispatcher, DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { DispatcherProvider, useQuery } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { describe, expect, test, vi } from "vitest";
|
|
6
|
+
import { act, createMockDispatcher, renderHook, waitFor } from "./test-utils";
|
|
7
|
+
|
|
8
|
+
function makeDispatcher(queryFn?: Dispatcher["query"]): Dispatcher {
|
|
9
|
+
return createMockDispatcher({ query: queryFn });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function wrap(dispatcher: Dispatcher) {
|
|
13
|
+
return ({ children }: { children: ReactNode }) => (
|
|
14
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("useQuery", () => {
|
|
19
|
+
test("loads on mount; populates data, flips loading to false", async () => {
|
|
20
|
+
const query = vi.fn(
|
|
21
|
+
async () => ({ isSuccess: true, data: [{ id: "1" }, { id: "2" }] }) as never,
|
|
22
|
+
);
|
|
23
|
+
const dispatcher = makeDispatcher(query as unknown as Dispatcher["query"]);
|
|
24
|
+
const { result } = renderHook(() => useQuery("task:list", {}), {
|
|
25
|
+
wrapper: wrap(dispatcher),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// First tick: loading state.
|
|
29
|
+
expect(result.current.loading).toBe(true);
|
|
30
|
+
expect(result.current.data).toBeNull();
|
|
31
|
+
|
|
32
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
33
|
+
expect(result.current.data).toEqual([{ id: "1" }, { id: "2" }]);
|
|
34
|
+
expect(result.current.error).toBeNull();
|
|
35
|
+
expect(query).toHaveBeenCalledOnce();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("server error surfaces via `error`; data stays null", async () => {
|
|
39
|
+
const err: DispatcherError = {
|
|
40
|
+
code: "not_found",
|
|
41
|
+
httpStatus: 404,
|
|
42
|
+
i18nKey: "errors.not_found",
|
|
43
|
+
message: "no",
|
|
44
|
+
};
|
|
45
|
+
const query = vi.fn(async () => ({ isSuccess: false, error: err }) as never);
|
|
46
|
+
const { result } = renderHook(() => useQuery("task:list", {}), {
|
|
47
|
+
wrapper: wrap(makeDispatcher(query as unknown as Dispatcher["query"])),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
51
|
+
expect(result.current.data).toBeNull();
|
|
52
|
+
expect(result.current.error?.code).toBe("not_found");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("enabled:false skips the auto-fetch until refetch is called", async () => {
|
|
56
|
+
const query = vi.fn(async () => ({ isSuccess: true, data: ["hi"] }) as never);
|
|
57
|
+
const { result } = renderHook(() => useQuery("task:list", {}, { enabled: false }), {
|
|
58
|
+
wrapper: wrap(makeDispatcher(query as unknown as Dispatcher["query"])),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
62
|
+
expect(query).not.toHaveBeenCalled();
|
|
63
|
+
expect(result.current.data).toBeNull();
|
|
64
|
+
|
|
65
|
+
await act(async () => {
|
|
66
|
+
await result.current.refetch();
|
|
67
|
+
});
|
|
68
|
+
expect(query).toHaveBeenCalledOnce();
|
|
69
|
+
expect(result.current.data).toEqual(["hi"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("refetch re-runs and replaces data (after-mutation reload pattern)", async () => {
|
|
73
|
+
let callCount = 0;
|
|
74
|
+
const query = vi.fn(async () => {
|
|
75
|
+
callCount += 1;
|
|
76
|
+
return { isSuccess: true, data: [callCount] } as never;
|
|
77
|
+
});
|
|
78
|
+
const { result } = renderHook(() => useQuery<number[]>("task:list", {}), {
|
|
79
|
+
wrapper: wrap(makeDispatcher(query as unknown as Dispatcher["query"])),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await waitFor(() => expect(result.current.data).toEqual([1]));
|
|
83
|
+
await act(async () => {
|
|
84
|
+
await result.current.refetch();
|
|
85
|
+
});
|
|
86
|
+
expect(result.current.data).toEqual([2]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { createStore, shallowEqual } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { useStore, useStoreSelector } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { describe, expect, test, vi } from "vitest";
|
|
5
|
+
import { act, renderHook } from "./test-utils";
|
|
6
|
+
|
|
7
|
+
describe("useStore", () => {
|
|
8
|
+
test("returns the current snapshot", () => {
|
|
9
|
+
const store = createStore({ count: 0 });
|
|
10
|
+
const { result } = renderHook(() => useStore(store));
|
|
11
|
+
expect(result.current).toEqual({ count: 0 });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("re-renders when setState changes the snapshot", () => {
|
|
15
|
+
const store = createStore({ count: 0 });
|
|
16
|
+
const { result } = renderHook(() => useStore(store));
|
|
17
|
+
|
|
18
|
+
act(() => {
|
|
19
|
+
store.setState({ count: 7 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(result.current).toEqual({ count: 7 });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("does not re-render when setState is a no-op (Object.is gate)", () => {
|
|
26
|
+
const store = createStore({ count: 0 });
|
|
27
|
+
let renderCount = 0;
|
|
28
|
+
renderHook(() => {
|
|
29
|
+
renderCount += 1;
|
|
30
|
+
return useStore(store);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const before = renderCount;
|
|
34
|
+
act(() => {
|
|
35
|
+
store.setState(store.getSnapshot()); // same ref
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(renderCount).toBe(before);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("useStoreSelector", () => {
|
|
43
|
+
test("returns the selected slice", () => {
|
|
44
|
+
const store = createStore({ a: 1, b: 2 });
|
|
45
|
+
const { result } = renderHook(() => useStoreSelector(store, (s) => s.a));
|
|
46
|
+
expect(result.current).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("re-renders only when the selected slice changes", () => {
|
|
50
|
+
const store = createStore({ a: 1, b: 2 });
|
|
51
|
+
let renderCount = 0;
|
|
52
|
+
const { result } = renderHook(() => {
|
|
53
|
+
renderCount += 1;
|
|
54
|
+
return useStoreSelector(store, (s) => s.a);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const beforeA = renderCount;
|
|
58
|
+
act(() => {
|
|
59
|
+
store.setState({ a: 1, b: 99 }); // unrelated slice changed
|
|
60
|
+
});
|
|
61
|
+
expect(renderCount).toBe(beforeA); // no re-render — `a` unchanged
|
|
62
|
+
expect(result.current).toBe(1);
|
|
63
|
+
|
|
64
|
+
act(() => {
|
|
65
|
+
store.setState({ a: 5, b: 99 }); // selected slice changed
|
|
66
|
+
});
|
|
67
|
+
expect(renderCount).toBe(beforeA + 1);
|
|
68
|
+
expect(result.current).toBe(5);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("default Object.is equality re-renders on every object-literal selector return", () => {
|
|
72
|
+
// This documents the trap that motivates the optional `equals` arg.
|
|
73
|
+
// Without a custom equality, a selector returning `{ a, b }` would
|
|
74
|
+
// produce a new object identity each notify and re-render forever.
|
|
75
|
+
// This test asserts that with the DEFAULT (Object.is), a stable-
|
|
76
|
+
// valued slice still works because the slice IS Object.is-equal.
|
|
77
|
+
const store = createStore({ a: 1, b: 2 });
|
|
78
|
+
let renderCount = 0;
|
|
79
|
+
renderHook(() => {
|
|
80
|
+
renderCount += 1;
|
|
81
|
+
return useStoreSelector(store, (s) => s.a + s.b);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const before = renderCount;
|
|
85
|
+
act(() => {
|
|
86
|
+
store.setState({ a: 1, b: 2 }); // same shape, but different ref
|
|
87
|
+
});
|
|
88
|
+
// Store's Object.is-gate blocks the notify entirely (same nextValue
|
|
89
|
+
// is rejected before listeners fire), so no re-evaluation happens.
|
|
90
|
+
// We assert the OUTER hook didn't re-render.
|
|
91
|
+
expect(renderCount).toBe(before);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("custom shallowEqual stabilizes object-literal selector returns", () => {
|
|
95
|
+
const store = createStore({ a: 1, b: 2, irrelevant: 0 });
|
|
96
|
+
let renderCount = 0;
|
|
97
|
+
const { result } = renderHook(() => {
|
|
98
|
+
renderCount += 1;
|
|
99
|
+
return useStoreSelector(store, (s) => ({ a: s.a, b: s.b }), shallowEqual);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const firstResult = result.current;
|
|
103
|
+
expect(firstResult).toEqual({ a: 1, b: 2 });
|
|
104
|
+
|
|
105
|
+
act(() => {
|
|
106
|
+
// Unrelated change in the snapshot — selector RETURNS a new object
|
|
107
|
+
// ({ a: 1, b: 2 } each time) but shallowEqual sees a/b unchanged.
|
|
108
|
+
store.setState({ a: 1, b: 2, irrelevant: 99 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Same identity preserved across the notify because shallowEqual
|
|
112
|
+
// matched. Without the equals-arg, this would be a new ref each call.
|
|
113
|
+
expect(result.current).toBe(firstResult);
|
|
114
|
+
expect(renderCount).toBe(1); // only the initial render
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("custom equals receives previous and current selected values", () => {
|
|
118
|
+
const store = createStore({ count: 0 });
|
|
119
|
+
const equals = vi.fn((_a: number, _b: number) => false); // never equal
|
|
120
|
+
let renderCount = 0;
|
|
121
|
+
renderHook(() => {
|
|
122
|
+
renderCount += 1;
|
|
123
|
+
return useStoreSelector(store, (s) => s.count, equals);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
act(() => {
|
|
127
|
+
store.setState({ count: 1 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// equals was called with (prev=0, next=1) at some point during the
|
|
131
|
+
// notify cycle. React may call getSnapshot multiple times per render,
|
|
132
|
+
// so we check that the transition (0, 1) appears among the calls
|
|
133
|
+
// rather than asserting on the last one.
|
|
134
|
+
expect(equals).toHaveBeenCalled();
|
|
135
|
+
const sawTransition = equals.mock.calls.some(([a, b]) => a === 0 && b === 1);
|
|
136
|
+
expect(sawTransition).toBe(true);
|
|
137
|
+
expect(renderCount).toBeGreaterThan(1);
|
|
138
|
+
});
|
|
139
|
+
});
|