mvc-kit 2.12.0 → 2.12.1
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/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +10 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { SortDescriptor } from '../../Sorting';
|
|
3
|
+
|
|
4
|
+
/** Props passed to a custom sort indicator render function. */
|
|
5
|
+
export interface SortHeaderProps {
|
|
6
|
+
active: boolean;
|
|
7
|
+
direction: 'asc' | 'desc';
|
|
8
|
+
index: number;
|
|
9
|
+
onToggle: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Controlled selection state using callback props. */
|
|
13
|
+
export interface SelectionState<K = string | number> {
|
|
14
|
+
selected: ReadonlySet<K>;
|
|
15
|
+
onToggle: (key: K) => void;
|
|
16
|
+
onToggleAll: (allKeys: K[]) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Structural interface matching Selection<K> — duck-type for direct helper pass-through. */
|
|
20
|
+
export interface SelectionHelper {
|
|
21
|
+
readonly selected: ReadonlySet<any>;
|
|
22
|
+
toggle(key: any): void;
|
|
23
|
+
toggleAll(allKeys: any[]): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Controlled pagination state using callback props. */
|
|
27
|
+
export interface PaginationState {
|
|
28
|
+
page: number;
|
|
29
|
+
total: number;
|
|
30
|
+
onPageChange: (page: number) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Structural interface matching Pagination — duck-type for direct helper pass-through. */
|
|
34
|
+
export interface PaginationHelper {
|
|
35
|
+
readonly page: number;
|
|
36
|
+
readonly pageSize: number;
|
|
37
|
+
setPage(page: number): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Structural interface matching Sorting<T> — duck-type for direct helper pass-through. */
|
|
41
|
+
export interface SortingHelper {
|
|
42
|
+
readonly sorts: readonly SortDescriptor[];
|
|
43
|
+
toggle(key: string): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Computed pagination info passed to renderPagination slots. */
|
|
47
|
+
export interface PaginationInfo {
|
|
48
|
+
page: number;
|
|
49
|
+
pageCount: number;
|
|
50
|
+
total: number;
|
|
51
|
+
pageSize: number;
|
|
52
|
+
hasPrev: boolean;
|
|
53
|
+
hasNext: boolean;
|
|
54
|
+
goToPage: (p: number) => void;
|
|
55
|
+
goPrev: () => void;
|
|
56
|
+
goNext: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Loading and error state props for async-aware components. */
|
|
60
|
+
export interface AsyncStateProps {
|
|
61
|
+
loading?: boolean;
|
|
62
|
+
error?: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Column definition for DataTable. */
|
|
66
|
+
export interface Column<T> {
|
|
67
|
+
key: string;
|
|
68
|
+
header: ReactNode;
|
|
69
|
+
render: (item: T, index: number) => ReactNode;
|
|
70
|
+
sortable?: boolean;
|
|
71
|
+
width?: string;
|
|
72
|
+
align?: 'left' | 'center' | 'right';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Detection functions (duck-type discriminators) ──
|
|
76
|
+
|
|
77
|
+
/** @internal Detect whether a selection prop is a Selection helper instance. */
|
|
78
|
+
export function isSelectionHelper(s: SelectionState | SelectionHelper): s is SelectionHelper {
|
|
79
|
+
return 'toggle' in s && !('onToggle' in s);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @internal Detect whether a pagination prop is a Pagination helper instance. */
|
|
83
|
+
export function isPaginationHelper(p: PaginationState | PaginationHelper): p is PaginationHelper {
|
|
84
|
+
return 'setPage' in p && !('onPageChange' in p);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @internal Detect whether a sort prop is a Sorting helper instance. */
|
|
88
|
+
export function isSortingHelper(s: readonly SortDescriptor[] | SortingHelper): s is SortingHelper {
|
|
89
|
+
return s != null && !Array.isArray(s) && 'sorts' in s && 'toggle' in s;
|
|
90
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
4
|
+
import { render, screen, act } from '@testing-library/react';
|
|
5
|
+
import { ViewModel } from '../ViewModel';
|
|
6
|
+
import { Collection } from '../Collection';
|
|
7
|
+
import { useLocal } from './use-local';
|
|
8
|
+
import { teardownAll } from '../singleton';
|
|
9
|
+
|
|
10
|
+
interface Item {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
status?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class TestCollection extends Collection<Item> {}
|
|
17
|
+
|
|
18
|
+
interface TestState {
|
|
19
|
+
search: string;
|
|
20
|
+
loading: boolean;
|
|
21
|
+
error: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class ItemsViewModel extends ViewModel<TestState> {
|
|
25
|
+
collection = new TestCollection();
|
|
26
|
+
computeCount = 0;
|
|
27
|
+
|
|
28
|
+
get filtered(): Item[] {
|
|
29
|
+
this.computeCount++;
|
|
30
|
+
const { search } = this.state;
|
|
31
|
+
let items = [...this.collection.items];
|
|
32
|
+
if (search) {
|
|
33
|
+
const q = search.toLowerCase();
|
|
34
|
+
items = items.filter((i) => i.name.toLowerCase().includes(q));
|
|
35
|
+
}
|
|
36
|
+
return items;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get total(): number {
|
|
40
|
+
return this.collection.length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get hasResults(): boolean {
|
|
44
|
+
return this.filtered.length > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setSearch(search: string) {
|
|
48
|
+
this.set({ search });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setLoading(loading: boolean) {
|
|
52
|
+
this.set({ loading });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const testData: Item[] = [
|
|
57
|
+
{ id: '1', name: 'Alice' },
|
|
58
|
+
{ id: '2', name: 'Bob' },
|
|
59
|
+
{ id: '3', name: 'Charlie' },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
describe('derived state - React integration', () => {
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
teardownAll();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('useLocal renders with initial state and getter values', () => {
|
|
68
|
+
function TestComponent() {
|
|
69
|
+
const [state, vm] = useLocal(ItemsViewModel, {
|
|
70
|
+
search: '',
|
|
71
|
+
loading: false,
|
|
72
|
+
error: null,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Populate collection on first render
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
vm.collection.reset(testData);
|
|
78
|
+
}, [vm]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div>
|
|
82
|
+
<span data-testid="search">{state.search}</span>
|
|
83
|
+
<span data-testid="filtered-count">{vm.filtered.length}</span>
|
|
84
|
+
<span data-testid="total">{vm.total}</span>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
render(<TestComponent />);
|
|
90
|
+
|
|
91
|
+
expect(screen.getByTestId('search').textContent).toBe('');
|
|
92
|
+
expect(screen.getByTestId('filtered-count').textContent).toBe('3');
|
|
93
|
+
expect(screen.getByTestId('total').textContent).toBe('3');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('state change triggers single re-render with consistent values', () => {
|
|
97
|
+
let renderCount = 0;
|
|
98
|
+
|
|
99
|
+
function TestComponent() {
|
|
100
|
+
const [state, vm] = useLocal(ItemsViewModel, {
|
|
101
|
+
search: '',
|
|
102
|
+
loading: false,
|
|
103
|
+
error: null,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
renderCount++;
|
|
107
|
+
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
vm.collection.reset(testData);
|
|
110
|
+
}, [vm]);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div>
|
|
114
|
+
<span data-testid="search">{state.search}</span>
|
|
115
|
+
<span data-testid="filtered-count">{vm.filtered.length}</span>
|
|
116
|
+
<span data-testid="total">{vm.total}</span>
|
|
117
|
+
<button data-testid="search-btn" onClick={() => vm.setSearch('alice')}>
|
|
118
|
+
Search
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
render(<TestComponent />);
|
|
125
|
+
const initialRenderCount = renderCount;
|
|
126
|
+
|
|
127
|
+
act(() => {
|
|
128
|
+
screen.getByTestId('search-btn').click();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Should have triggered exactly one additional render
|
|
132
|
+
expect(renderCount).toBe(initialRenderCount + 1);
|
|
133
|
+
expect(screen.getByTestId('search').textContent).toBe('alice');
|
|
134
|
+
expect(screen.getByTestId('filtered-count').textContent).toBe('1');
|
|
135
|
+
expect(screen.getByTestId('total').textContent).toBe('3');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('collection change triggers re-render with updated getters', () => {
|
|
139
|
+
function TestComponent() {
|
|
140
|
+
const [_, vm] = useLocal(ItemsViewModel, {
|
|
141
|
+
search: '',
|
|
142
|
+
loading: false,
|
|
143
|
+
error: null,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div>
|
|
148
|
+
<span data-testid="filtered-count">{vm.filtered.length}</span>
|
|
149
|
+
<span data-testid="total">{vm.total}</span>
|
|
150
|
+
<button
|
|
151
|
+
data-testid="populate-btn"
|
|
152
|
+
onClick={() => vm.collection.reset(testData)}
|
|
153
|
+
>
|
|
154
|
+
Populate
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
render(<TestComponent />);
|
|
161
|
+
|
|
162
|
+
expect(screen.getByTestId('filtered-count').textContent).toBe('0');
|
|
163
|
+
expect(screen.getByTestId('total').textContent).toBe('0');
|
|
164
|
+
|
|
165
|
+
act(() => {
|
|
166
|
+
screen.getByTestId('populate-btn').click();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(screen.getByTestId('filtered-count').textContent).toBe('3');
|
|
170
|
+
expect(screen.getByTestId('total').textContent).toBe('3');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('unrelated state change does not recompute getters', () => {
|
|
174
|
+
function TestComponent() {
|
|
175
|
+
const [state, vm] = useLocal(ItemsViewModel, {
|
|
176
|
+
search: '',
|
|
177
|
+
loading: false,
|
|
178
|
+
error: null,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
React.useEffect(() => {
|
|
182
|
+
vm.collection.reset(testData);
|
|
183
|
+
}, [vm]);
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div>
|
|
187
|
+
<span data-testid="loading">{String(state.loading)}</span>
|
|
188
|
+
<span data-testid="filtered-count">{vm.filtered.length}</span>
|
|
189
|
+
<span data-testid="compute-count">{vm.computeCount}</span>
|
|
190
|
+
<button data-testid="loading-btn" onClick={() => vm.setLoading(true)}>
|
|
191
|
+
Load
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
render(<TestComponent />);
|
|
198
|
+
|
|
199
|
+
// Initial render computes the getter
|
|
200
|
+
const initialComputeCount = parseInt(
|
|
201
|
+
screen.getByTestId('compute-count').textContent || '0'
|
|
202
|
+
);
|
|
203
|
+
expect(initialComputeCount).toBeGreaterThan(0);
|
|
204
|
+
|
|
205
|
+
act(() => {
|
|
206
|
+
screen.getByTestId('loading-btn').click();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Component re-renders (loading state changed), but getter returns cached value
|
|
210
|
+
expect(screen.getByTestId('loading').textContent).toBe('true');
|
|
211
|
+
expect(screen.getByTestId('filtered-count').textContent).toBe('3');
|
|
212
|
+
expect(screen.getByTestId('compute-count').textContent).toBe(
|
|
213
|
+
String(initialComputeCount)
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('React StrictMode double-mount works correctly', () => {
|
|
218
|
+
function TestComponent() {
|
|
219
|
+
const [_, vm] = useLocal(ItemsViewModel, {
|
|
220
|
+
search: '',
|
|
221
|
+
loading: false,
|
|
222
|
+
error: null,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
React.useEffect(() => {
|
|
226
|
+
vm.collection.reset(testData);
|
|
227
|
+
}, [vm]);
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div>
|
|
231
|
+
<span data-testid="filtered-count">{vm.filtered.length}</span>
|
|
232
|
+
<span data-testid="total">{vm.total}</span>
|
|
233
|
+
<span data-testid="has-results">{String(vm.hasResults)}</span>
|
|
234
|
+
<button data-testid="search-btn" onClick={() => vm.setSearch('alice')}>
|
|
235
|
+
Search
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// StrictMode will mount, unmount, and remount the component in dev mode
|
|
242
|
+
render(
|
|
243
|
+
<React.StrictMode>
|
|
244
|
+
<TestComponent />
|
|
245
|
+
</React.StrictMode>
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Should work without errors
|
|
249
|
+
expect(screen.getByTestId('filtered-count').textContent).toBe('3');
|
|
250
|
+
expect(screen.getByTestId('total').textContent).toBe('3');
|
|
251
|
+
expect(screen.getByTestId('has-results').textContent).toBe('true');
|
|
252
|
+
|
|
253
|
+
// Interactions should still work correctly
|
|
254
|
+
act(() => {
|
|
255
|
+
screen.getByTestId('search-btn').click();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(screen.getByTestId('filtered-count').textContent).toBe('1');
|
|
259
|
+
expect(screen.getByTestId('has-results').textContent).toBe('true');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Subscribable } from '../types';
|
|
2
|
+
|
|
3
|
+
/** @internal Type guard for Subscribable */
|
|
4
|
+
export const isSubscribable = (obj: unknown): obj is Subscribable<unknown> =>
|
|
5
|
+
obj !== null &&
|
|
6
|
+
typeof obj === 'object' &&
|
|
7
|
+
'state' in obj &&
|
|
8
|
+
'subscribe' in obj &&
|
|
9
|
+
typeof (obj as Subscribable<unknown>).subscribe === 'function';
|
|
10
|
+
|
|
11
|
+
/** @internal Type guard for Initializable */
|
|
12
|
+
export const isInitializable = (obj: unknown): obj is { init(): void | Promise<void> } =>
|
|
13
|
+
obj !== null &&
|
|
14
|
+
typeof obj === 'object' &&
|
|
15
|
+
'init' in obj &&
|
|
16
|
+
typeof (obj as any).init === 'function';
|
|
17
|
+
|
|
18
|
+
/** @internal Type guard for subscribe-only objects (has subscribe but no state). */
|
|
19
|
+
export const isSubscribeOnly = (obj: unknown): obj is { subscribe(cb: () => void): () => void } =>
|
|
20
|
+
obj !== null &&
|
|
21
|
+
typeof obj === 'object' &&
|
|
22
|
+
!('state' in obj) &&
|
|
23
|
+
'subscribe' in obj &&
|
|
24
|
+
typeof (obj as any).subscribe === 'function';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
export type { StateOf, ItemOf, SingletonClass, ProviderRegistry } from './types';
|
|
3
|
+
|
|
4
|
+
// Generic reactive state bindings
|
|
5
|
+
export { useInstance } from './use-instance';
|
|
6
|
+
export { useLocal } from './use-local';
|
|
7
|
+
export { useSingleton } from './use-singleton';
|
|
8
|
+
|
|
9
|
+
// Model (specialized - returns rich ModelHandle with validation)
|
|
10
|
+
export { useModel, useModelRef, useField } from './use-model';
|
|
11
|
+
export type { ModelHandle, FieldHandle } from './use-model';
|
|
12
|
+
|
|
13
|
+
// EventBus (specialized)
|
|
14
|
+
export { useEvent, useEmit } from './use-event-bus';
|
|
15
|
+
|
|
16
|
+
// Utilities
|
|
17
|
+
export { useTeardown } from './use-teardown';
|
|
18
|
+
export { Provider, useResolve } from './provider';
|
|
19
|
+
export type { ProviderProps } from './provider';
|
|
20
|
+
|
|
21
|
+
// Headless components
|
|
22
|
+
export { DataTable } from './components/DataTable';
|
|
23
|
+
export type { DataTableProps } from './components/DataTable';
|
|
24
|
+
export { CardList } from './components/CardList';
|
|
25
|
+
export type { CardListProps } from './components/CardList';
|
|
26
|
+
export { InfiniteScroll } from './components/InfiniteScroll';
|
|
27
|
+
export type { InfiniteScrollProps } from './components/InfiniteScroll';
|
|
28
|
+
|
|
29
|
+
// Component types
|
|
30
|
+
export type {
|
|
31
|
+
Column,
|
|
32
|
+
SortHeaderProps,
|
|
33
|
+
SelectionState,
|
|
34
|
+
SelectionHelper,
|
|
35
|
+
PaginationState,
|
|
36
|
+
PaginationHelper,
|
|
37
|
+
PaginationInfo,
|
|
38
|
+
SortingHelper,
|
|
39
|
+
AsyncStateProps,
|
|
40
|
+
} from './components/types';
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen, act } from '@testing-library/react';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { Service } from '../Service';
|
|
7
|
+
import { ViewModel } from '../ViewModel';
|
|
8
|
+
import { teardownAll } from '../singleton';
|
|
9
|
+
import { Provider, useResolve } from './provider';
|
|
10
|
+
|
|
11
|
+
class ApiService extends Service {
|
|
12
|
+
fetchData(): string {
|
|
13
|
+
return 'real-data';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class MockApiService extends Service {
|
|
18
|
+
fetchData(): string {
|
|
19
|
+
return 'mock-data';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CountState {
|
|
24
|
+
count: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class CounterVM extends ViewModel<CountState> {
|
|
28
|
+
constructor() {
|
|
29
|
+
super({ count: 0 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
increment() {
|
|
33
|
+
this.set((prev) => ({ count: prev.count + 1 }));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ApiConsumer() {
|
|
38
|
+
const api = useResolve(ApiService);
|
|
39
|
+
const [data, setData] = useState('');
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div>
|
|
43
|
+
<div data-testid="data">{data}</div>
|
|
44
|
+
<button onClick={() => setData(api.fetchData())}>Fetch</button>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function CounterConsumer() {
|
|
50
|
+
const vm = useResolve(CounterVM);
|
|
51
|
+
return <div data-testid="count">{vm.state.count}</div>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('Provider and useResolve', () => {
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
teardownAll();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should resolve from provider when provided', () => {
|
|
60
|
+
const mockApi = new MockApiService();
|
|
61
|
+
|
|
62
|
+
render(
|
|
63
|
+
<Provider provide={[[ApiService, mockApi]]}>
|
|
64
|
+
<ApiConsumer />
|
|
65
|
+
</Provider>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
act(() => {
|
|
69
|
+
screen.getByRole('button').click();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(screen.getByTestId('data').textContent).toBe('mock-data');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should fallback to singleton when not provided', () => {
|
|
76
|
+
render(<ApiConsumer />);
|
|
77
|
+
|
|
78
|
+
act(() => {
|
|
79
|
+
screen.getByRole('button').click();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(screen.getByTestId('data').textContent).toBe('real-data');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should work with ViewModels', () => {
|
|
86
|
+
const mockVm = new CounterVM();
|
|
87
|
+
mockVm.increment();
|
|
88
|
+
mockVm.increment();
|
|
89
|
+
|
|
90
|
+
render(
|
|
91
|
+
<Provider provide={[[CounterVM, mockVm]]}>
|
|
92
|
+
<CounterConsumer />
|
|
93
|
+
</Provider>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(screen.getByTestId('count').textContent).toBe('2');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should provide multiple dependencies', () => {
|
|
100
|
+
const mockApi = new MockApiService();
|
|
101
|
+
const mockVm = new CounterVM();
|
|
102
|
+
mockVm.increment();
|
|
103
|
+
|
|
104
|
+
render(
|
|
105
|
+
<Provider
|
|
106
|
+
provide={[
|
|
107
|
+
[ApiService, mockApi],
|
|
108
|
+
[CounterVM, mockVm],
|
|
109
|
+
]}
|
|
110
|
+
>
|
|
111
|
+
<ApiConsumer />
|
|
112
|
+
<CounterConsumer />
|
|
113
|
+
</Provider>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
act(() => {
|
|
117
|
+
screen.getByRole('button').click();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(screen.getByTestId('data').textContent).toBe('mock-data');
|
|
121
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should allow nested providers', () => {
|
|
125
|
+
const outerMock = new MockApiService();
|
|
126
|
+
const innerVm = new CounterVM();
|
|
127
|
+
innerVm.increment();
|
|
128
|
+
|
|
129
|
+
render(
|
|
130
|
+
<Provider provide={[[ApiService, outerMock]]}>
|
|
131
|
+
<Provider provide={[[CounterVM, innerVm]]}>
|
|
132
|
+
<ApiConsumer />
|
|
133
|
+
<CounterConsumer />
|
|
134
|
+
</Provider>
|
|
135
|
+
</Provider>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Note: Inner provider only has CounterVM, so ApiService
|
|
139
|
+
// will fallback to singleton (not outer mock) due to context replacement
|
|
140
|
+
// This is expected React context behavior
|
|
141
|
+
expect(screen.getByTestId('count').textContent).toBe('1');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
2
|
+
import { singleton } from '../singleton';
|
|
3
|
+
import type { Disposable } from '../types';
|
|
4
|
+
import type { ProviderRegistry } from './types';
|
|
5
|
+
|
|
6
|
+
const ProviderContext = createContext<ProviderRegistry | null>(null);
|
|
7
|
+
|
|
8
|
+
/** Props for the `Provider` component used to inject test/Storybook dependencies. */
|
|
9
|
+
export interface ProviderProps {
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
provide: Array<[new (...args: any[]) => any, any]>;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* DI container for testing and Storybook.
|
|
17
|
+
*/
|
|
18
|
+
export function Provider({ provide, children }: ProviderProps): ReactNode {
|
|
19
|
+
const registry = useMemo(() => {
|
|
20
|
+
const map: ProviderRegistry = new Map();
|
|
21
|
+
for (const [Class, instance] of provide) {
|
|
22
|
+
map.set(Class, instance);
|
|
23
|
+
}
|
|
24
|
+
return map;
|
|
25
|
+
}, [provide]);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<ProviderContext.Provider value={registry}>
|
|
29
|
+
{children}
|
|
30
|
+
</ProviderContext.Provider>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve from Provider context or fallback to singleton().
|
|
36
|
+
* If the class defines `static DEFAULT_STATE`, no constructor args are needed.
|
|
37
|
+
*/
|
|
38
|
+
export function useResolve<T>(
|
|
39
|
+
Class: (new (...args: any[]) => T) & { DEFAULT_STATE: unknown },
|
|
40
|
+
): T;
|
|
41
|
+
export function useResolve<T, Args extends unknown[] = unknown[]>(
|
|
42
|
+
Class: new (...args: Args) => T,
|
|
43
|
+
...args: Args
|
|
44
|
+
): T;
|
|
45
|
+
export function useResolve<T, Args extends unknown[] = unknown[]>(
|
|
46
|
+
Class: (new (...args: Args) => T) & { DEFAULT_STATE?: unknown },
|
|
47
|
+
...args: Args
|
|
48
|
+
): T {
|
|
49
|
+
const registry = useContext(ProviderContext);
|
|
50
|
+
|
|
51
|
+
if (registry?.has(Class)) {
|
|
52
|
+
return registry.get(Class) as T;
|
|
53
|
+
}
|
|
54
|
+
return singleton(Class as new (...args: Args) => T & Disposable, ...args);
|
|
55
|
+
}
|