mvc-kit 2.13.0 → 2.13.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/BEST_PRACTICES.md +1390 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
- package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
- package/agent-config/lib/install-claude.mjs +8 -2
- package/examples/primitive/channel.ts +109 -0
- package/examples/primitive/collection.ts +118 -0
- package/examples/primitive/controller.ts +118 -0
- package/examples/primitive/counter.ts +108 -0
- package/examples/primitive/env.d.ts +1 -0
- package/examples/primitive/eventbus.ts +77 -0
- package/examples/primitive/feed.ts +162 -0
- package/examples/primitive/model.ts +82 -0
- package/examples/primitive/pagination.ts +91 -0
- package/examples/primitive/pending.ts +189 -0
- package/examples/primitive/persistent-collection.ts +116 -0
- package/examples/primitive/resource.ts +114 -0
- package/examples/primitive/selection.ts +96 -0
- package/examples/primitive/sorting.ts +112 -0
- package/examples/primitive/timer.ts +58 -0
- package/examples/primitive/trackable.ts +225 -0
- package/examples/primitive/tsconfig.json +20 -0
- package/examples/primitive/viewmodel-service.ts +161 -0
- package/examples/react/AuthExample/index.html +12 -0
- package/examples/react/AuthExample/src/App.tsx +29 -0
- package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
- package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
- package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
- package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
- package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
- package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
- package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
- package/examples/react/AuthExample/src/env.d.ts +10 -0
- package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
- package/examples/react/AuthExample/src/main.tsx +10 -0
- package/examples/react/AuthExample/src/mock/api.ts +78 -0
- package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
- package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
- package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
- package/examples/react/AuthExample/src/styles.css +445 -0
- package/examples/react/AuthExample/src/types/auth.ts +12 -0
- package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
- package/examples/react/AuthExample/tsconfig.json +22 -0
- package/examples/react/AuthExample/vite.config.ts +18 -0
- package/examples/react/ComplexApp/index.html +12 -0
- package/examples/react/ComplexApp/src/App.tsx +17 -0
- package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
- package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
- package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
- package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
- package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
- package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
- package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
- package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
- package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
- package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
- package/examples/react/ComplexApp/src/env.d.ts +10 -0
- package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/ComplexApp/src/main.tsx +10 -0
- package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
- package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
- package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
- package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
- package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
- package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
- package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
- package/examples/react/ComplexApp/src/styles.css +463 -0
- package/examples/react/ComplexApp/src/types/activity.ts +8 -0
- package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
- package/examples/react/ComplexApp/src/types/social.ts +8 -0
- package/examples/react/ComplexApp/src/types/users.ts +6 -0
- package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
- package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
- package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
- package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
- package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/tsconfig.json +22 -0
- package/examples/react/ComplexApp/vite.config.ts +18 -0
- package/examples/react/FullApp/index.html +12 -0
- package/examples/react/FullApp/src/App.tsx +28 -0
- package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
- package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
- package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
- package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
- package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
- package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
- package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
- package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
- package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
- package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
- package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
- package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
- package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
- package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
- package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
- package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
- package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
- package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
- package/examples/react/FullApp/src/env.d.ts +10 -0
- package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/FullApp/src/main.tsx +10 -0
- package/examples/react/FullApp/src/mock/delay.ts +21 -0
- package/examples/react/FullApp/src/mock/locations.ts +76 -0
- package/examples/react/FullApp/src/mock/messages.ts +237 -0
- package/examples/react/FullApp/src/mock/users.ts +84 -0
- package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
- package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
- package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
- package/examples/react/FullApp/src/services/AuthService.ts +18 -0
- package/examples/react/FullApp/src/services/LocationService.ts +23 -0
- package/examples/react/FullApp/src/services/MessageService.ts +65 -0
- package/examples/react/FullApp/src/services/UserService.ts +23 -0
- package/examples/react/FullApp/src/styles.css +767 -0
- package/examples/react/FullApp/src/types/conversation.ts +7 -0
- package/examples/react/FullApp/src/types/location.ts +12 -0
- package/examples/react/FullApp/src/types/message.ts +7 -0
- package/examples/react/FullApp/src/types/user.ts +10 -0
- package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
- package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
- package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
- package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
- package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
- package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
- package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
- package/examples/react/FullApp/tsconfig.json +22 -0
- package/examples/react/FullApp/vite.config.ts +18 -0
- package/examples/react/WorkerApp/index.html +12 -0
- package/examples/react/WorkerApp/src/App.tsx +24 -0
- package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
- package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
- package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
- package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
- package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
- package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
- package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
- package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
- package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
- package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
- package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
- package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
- package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
- package/examples/react/WorkerApp/src/env.d.ts +10 -0
- package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/WorkerApp/src/main.tsx +10 -0
- package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
- package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
- package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
- package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
- package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
- package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
- package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
- package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
- package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
- package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
- package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
- package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
- package/examples/react/WorkerApp/src/styles.css +756 -0
- package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
- package/examples/react/WorkerApp/src/types/message.ts +7 -0
- package/examples/react/WorkerApp/src/types/shift.ts +13 -0
- package/examples/react/WorkerApp/src/types/site.ts +8 -0
- package/examples/react/WorkerApp/src/types/worker.ts +8 -0
- package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
- package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
- package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
- package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
- package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
- package/examples/react/WorkerApp/tsconfig.json +22 -0
- package/examples/react/WorkerApp/vite.config.ts +18 -0
- package/package.json +4 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Resource, singleton, teardownAll } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Resource: Collection + async tracking + lifecycle
|
|
4
|
+
//
|
|
5
|
+
// Use Resource when you need a shared data cache with built-in
|
|
6
|
+
// loading/error state for your API calls. It extends Collection
|
|
7
|
+
// with init()/dispose() lifecycle and automatic async method tracking.
|
|
8
|
+
//
|
|
9
|
+
// If you already have a typed API client (tRPC, GraphQL codegen, etc.),
|
|
10
|
+
// call it directly from the Resource — no Service wrapper needed.
|
|
11
|
+
|
|
12
|
+
// --- Entity type ---
|
|
13
|
+
|
|
14
|
+
interface Product {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
price: number;
|
|
18
|
+
category: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Resource definition ---
|
|
22
|
+
|
|
23
|
+
class ProductsResource extends Resource<Product> {
|
|
24
|
+
// All async methods are automatically tracked after init().
|
|
25
|
+
// Access loading/error state via resource.async.methodName
|
|
26
|
+
|
|
27
|
+
async loadAll() {
|
|
28
|
+
// Pass disposeSignal to cancel in-flight requests on teardown.
|
|
29
|
+
// In production, this would call your API client or fetch().
|
|
30
|
+
const products = await fakeFetch<Product[]>('/api/products', this.disposeSignal);
|
|
31
|
+
this.reset(products);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async loadByCategory(category: string) {
|
|
35
|
+
const products = await fakeFetch<Product[]>(
|
|
36
|
+
`/api/products?category=${category}`,
|
|
37
|
+
this.disposeSignal,
|
|
38
|
+
);
|
|
39
|
+
// upsert: add new items or replace existing ones by ID (preserves other items)
|
|
40
|
+
this.upsert(...products);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async create(data: Omit<Product, 'id'>) {
|
|
44
|
+
const product = await fakeFetch<Product>('/api/products', this.disposeSignal);
|
|
45
|
+
this.add(product);
|
|
46
|
+
return product;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Lifecycle hook: called after init()
|
|
50
|
+
protected onInit() {
|
|
51
|
+
// Smart-init: only load if the collection is empty.
|
|
52
|
+
// Prevents redundant fetches when multiple consumers share the singleton.
|
|
53
|
+
if (this.length === 0) this.loadAll();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Usage ---
|
|
58
|
+
|
|
59
|
+
const products = singleton(ProductsResource);
|
|
60
|
+
await products.init(); // wraps methods for async tracking, calls onInit()
|
|
61
|
+
|
|
62
|
+
// Async tracking — no manual loading/error flags needed
|
|
63
|
+
console.log('Loading:', products.async.loadAll.loading); // true (load started in onInit)
|
|
64
|
+
|
|
65
|
+
// Wait for load to complete
|
|
66
|
+
await products.loadAll();
|
|
67
|
+
|
|
68
|
+
console.log('Loading:', products.async.loadAll.loading); // false
|
|
69
|
+
console.log('Error:', products.async.loadAll.error); // null
|
|
70
|
+
console.log('Total:', products.length); // 3
|
|
71
|
+
|
|
72
|
+
// All Collection methods are available
|
|
73
|
+
console.log('Items:', products.items);
|
|
74
|
+
console.log('Get by id:', products.get('1')?.name);
|
|
75
|
+
console.log('Filter:', products.filter(p => p.price > 20).length);
|
|
76
|
+
|
|
77
|
+
// Subscribe to data changes (inherited from Collection)
|
|
78
|
+
products.subscribe((items) => {
|
|
79
|
+
console.log(`Products changed: ${items.length} items`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Subscribe to async state changes (loading/error)
|
|
83
|
+
products.subscribeAsync(() => {
|
|
84
|
+
console.log('Async state changed:', products.async.loadAll);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Load more data — upsert merges without duplicates
|
|
88
|
+
await products.loadByCategory('electronics');
|
|
89
|
+
|
|
90
|
+
// Cleanup
|
|
91
|
+
products.dispose();
|
|
92
|
+
teardownAll();
|
|
93
|
+
|
|
94
|
+
// --- Fake API helper ---
|
|
95
|
+
|
|
96
|
+
function fakeFetch<T>(_url: string, signal?: AbortSignal): Promise<T> {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
if (signal?.aborted) {
|
|
99
|
+
reject(signal.reason);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
resolve([
|
|
104
|
+
{ id: '1', name: 'Widget', price: 9.99, category: 'gadgets' },
|
|
105
|
+
{ id: '2', name: 'Gizmo', price: 24.99, category: 'gadgets' },
|
|
106
|
+
{ id: '3', name: 'Doohickey', price: 49.99, category: 'electronics' },
|
|
107
|
+
] as unknown as T);
|
|
108
|
+
}, 100);
|
|
109
|
+
signal?.addEventListener('abort', () => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
reject(signal.reason);
|
|
112
|
+
}, { once: true });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Selection } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Selection: Key-based selection set with toggle and select-all
|
|
4
|
+
//
|
|
5
|
+
// A subscribable helper that manages a Set of selected keys. Designed
|
|
6
|
+
// to be a property on a ViewModel — auto-tracked so ViewModel getters
|
|
7
|
+
// that read selection state recompute automatically.
|
|
8
|
+
//
|
|
9
|
+
// Methods are auto-bound, so they can be passed point-free as callbacks
|
|
10
|
+
// (e.g., onChange={selection.toggle}).
|
|
11
|
+
|
|
12
|
+
// --- Entity type ---
|
|
13
|
+
|
|
14
|
+
interface File {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
size: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const files: File[] = [
|
|
21
|
+
{ id: '1', name: 'report.pdf', size: 2400 },
|
|
22
|
+
{ id: '2', name: 'photo.jpg', size: 5100 },
|
|
23
|
+
{ id: '3', name: 'notes.txt', size: 120 },
|
|
24
|
+
{ id: '4', name: 'backup.zip', size: 98000 },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const allIds = files.map(f => f.id);
|
|
28
|
+
|
|
29
|
+
// --- Basic usage ---
|
|
30
|
+
|
|
31
|
+
const selection = new Selection<string>();
|
|
32
|
+
|
|
33
|
+
// Subscribe to selection changes
|
|
34
|
+
selection.subscribe(() => {
|
|
35
|
+
console.log('Selected:', [...selection.selected]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Read current state
|
|
39
|
+
console.log('Count:', selection.count); // 0
|
|
40
|
+
console.log('Has selection:', selection.hasSelection); // false
|
|
41
|
+
|
|
42
|
+
// --- Toggle ---
|
|
43
|
+
|
|
44
|
+
selection.toggle('1');
|
|
45
|
+
console.log('After toggle 1:', selection.isSelected('1')); // true
|
|
46
|
+
|
|
47
|
+
selection.toggle('3');
|
|
48
|
+
console.log('Count:', selection.count); // 2
|
|
49
|
+
|
|
50
|
+
selection.toggle('1'); // toggle off
|
|
51
|
+
console.log('After toggle 1 again:', selection.isSelected('1')); // false
|
|
52
|
+
console.log('Count:', selection.count); // 1
|
|
53
|
+
|
|
54
|
+
// --- Select / Deselect ---
|
|
55
|
+
|
|
56
|
+
selection.select('1', '2', '4');
|
|
57
|
+
console.log('After select:', selection.count); // 4 (3 already had item 3)
|
|
58
|
+
|
|
59
|
+
selection.deselect('2', '4');
|
|
60
|
+
console.log('After deselect:', selection.count); // 2 (items 1 and 3)
|
|
61
|
+
|
|
62
|
+
// --- Toggle all ---
|
|
63
|
+
|
|
64
|
+
// If not all are selected, selects all
|
|
65
|
+
selection.toggleAll(allIds);
|
|
66
|
+
console.log('After toggleAll (select):', selection.count); // 4
|
|
67
|
+
|
|
68
|
+
// If all are selected, deselects all
|
|
69
|
+
selection.toggleAll(allIds);
|
|
70
|
+
console.log('After toggleAll (deselect):', selection.count); // 0
|
|
71
|
+
|
|
72
|
+
// --- Set (atomic replace) ---
|
|
73
|
+
|
|
74
|
+
selection.set('2', '3');
|
|
75
|
+
console.log('After set:', [...selection.selected]); // ['2', '3']
|
|
76
|
+
|
|
77
|
+
// --- Filter items by selection ---
|
|
78
|
+
|
|
79
|
+
selection.set('1', '4');
|
|
80
|
+
const selectedFiles = selection.selectedFrom(files, f => f.id);
|
|
81
|
+
console.log('Selected files:', selectedFiles.map(f => f.name));
|
|
82
|
+
// ['report.pdf', 'backup.zip']
|
|
83
|
+
|
|
84
|
+
// --- Point-free callbacks ---
|
|
85
|
+
|
|
86
|
+
// Methods are auto-bound, so you can pass them directly:
|
|
87
|
+
// <input onChange={selection.toggle} />
|
|
88
|
+
// <button onClick={() => selection.toggleAll(allIds)} />
|
|
89
|
+
const toggleFn = selection.toggle; // no .bind() needed
|
|
90
|
+
toggleFn('3');
|
|
91
|
+
console.log('After point-free toggle:', selection.isSelected('3')); // true
|
|
92
|
+
|
|
93
|
+
// --- Clear ---
|
|
94
|
+
|
|
95
|
+
selection.clear();
|
|
96
|
+
console.log('After clear:', selection.count); // 0
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Sorting } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Sorting: Multi-column sort state with a comparator pipeline
|
|
4
|
+
//
|
|
5
|
+
// A subscribable helper that manages sort descriptors and applies them
|
|
6
|
+
// to arrays. Designed to be a property on a ViewModel — auto-tracked
|
|
7
|
+
// so ViewModel getters that read sorting state recompute automatically.
|
|
8
|
+
//
|
|
9
|
+
// Three-click cycle: unsorted → asc → desc → removed.
|
|
10
|
+
|
|
11
|
+
// --- Entity type ---
|
|
12
|
+
|
|
13
|
+
interface Employee {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
department: string;
|
|
17
|
+
salary: number;
|
|
18
|
+
startDate: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const employees: Employee[] = [
|
|
22
|
+
{ id: '1', name: 'Alice', department: 'Engineering', salary: 120000, startDate: '2020-03-15' },
|
|
23
|
+
{ id: '2', name: 'Bob', department: 'Marketing', salary: 85000, startDate: '2021-06-01' },
|
|
24
|
+
{ id: '3', name: 'Carol', department: 'Engineering', salary: 140000, startDate: '2019-01-10' },
|
|
25
|
+
{ id: '4', name: 'Dave', department: 'Marketing', salary: 92000, startDate: '2022-09-20' },
|
|
26
|
+
{ id: '5', name: 'Eve', department: 'Engineering', salary: 110000, startDate: '2023-02-01' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// --- Basic usage ---
|
|
30
|
+
|
|
31
|
+
const sorting = new Sorting<Employee>();
|
|
32
|
+
|
|
33
|
+
// Subscribe to sort state changes
|
|
34
|
+
sorting.subscribe(() => {
|
|
35
|
+
console.log('Sort changed:', sorting.sorts);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Read current state
|
|
39
|
+
console.log('Key:', sorting.key); // null (no sort active)
|
|
40
|
+
console.log('Direction:', sorting.direction); // 'asc' (default)
|
|
41
|
+
|
|
42
|
+
// Toggle cycles: unsorted → asc → desc → removed
|
|
43
|
+
sorting.toggle('name');
|
|
44
|
+
console.log('After first toggle:', sorting.key, sorting.direction); // 'name', 'asc'
|
|
45
|
+
|
|
46
|
+
sorting.toggle('name');
|
|
47
|
+
console.log('After second toggle:', sorting.key, sorting.direction); // 'name', 'desc'
|
|
48
|
+
|
|
49
|
+
sorting.toggle('name');
|
|
50
|
+
console.log('After third toggle:', sorting.key); // null (removed)
|
|
51
|
+
|
|
52
|
+
// --- Apply sort pipeline ---
|
|
53
|
+
|
|
54
|
+
sorting.setSort('salary', 'desc');
|
|
55
|
+
|
|
56
|
+
const bySalary = sorting.apply(employees);
|
|
57
|
+
console.log('By salary (desc):', bySalary.map(e => `${e.name}: $${e.salary}`));
|
|
58
|
+
// Carol: $140000, Alice: $120000, Eve: $110000, Dave: $92000, Bob: $85000
|
|
59
|
+
|
|
60
|
+
// --- Query methods ---
|
|
61
|
+
|
|
62
|
+
console.log('Is salary sorted:', sorting.isSorted('salary')); // true
|
|
63
|
+
console.log('Salary direction:', sorting.directionOf('salary')); // 'desc'
|
|
64
|
+
console.log('Name direction:', sorting.directionOf('name')); // null (not sorted)
|
|
65
|
+
|
|
66
|
+
// --- Multi-column sort ---
|
|
67
|
+
|
|
68
|
+
// setSorts replaces all descriptors
|
|
69
|
+
sorting.setSorts([
|
|
70
|
+
{ key: 'department', direction: 'asc' },
|
|
71
|
+
{ key: 'salary', direction: 'desc' },
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const multiSorted = sorting.apply(employees);
|
|
75
|
+
console.log('Multi-sort (dept asc, salary desc):');
|
|
76
|
+
multiSorted.forEach(e => console.log(` ${e.department} — ${e.name}: $${e.salary}`));
|
|
77
|
+
// Engineering — Carol: $140000
|
|
78
|
+
// Engineering — Alice: $120000
|
|
79
|
+
// Engineering — Eve: $110000
|
|
80
|
+
// Marketing — Dave: $92000
|
|
81
|
+
// Marketing — Bob: $85000
|
|
82
|
+
|
|
83
|
+
console.log('Salary index in sort list:', sorting.indexOf('salary')); // 1
|
|
84
|
+
|
|
85
|
+
// --- Custom comparator ---
|
|
86
|
+
|
|
87
|
+
// Custom compareFn for special types (dates, locale-specific strings, etc.)
|
|
88
|
+
sorting.setSort('startDate', 'asc');
|
|
89
|
+
|
|
90
|
+
const byDate = sorting.apply(employees, (a, b, key, dir) => {
|
|
91
|
+
const aVal = a[key as keyof Employee];
|
|
92
|
+
const bVal = b[key as keyof Employee];
|
|
93
|
+
// Date strings sort correctly with localeCompare, but you could parse to Date
|
|
94
|
+
const cmp = String(aVal).localeCompare(String(bVal));
|
|
95
|
+
return dir === 'asc' ? cmp : -cmp;
|
|
96
|
+
});
|
|
97
|
+
console.log('By start date:', byDate.map(e => `${e.name} (${e.startDate})`));
|
|
98
|
+
|
|
99
|
+
// --- Initial sorts via constructor ---
|
|
100
|
+
|
|
101
|
+
const preConfigured = new Sorting<Employee>({
|
|
102
|
+
sorts: [{ key: 'name', direction: 'asc' }],
|
|
103
|
+
});
|
|
104
|
+
console.log('Pre-configured key:', preConfigured.key); // 'name'
|
|
105
|
+
|
|
106
|
+
const sorted = preConfigured.apply(employees);
|
|
107
|
+
console.log('Pre-sorted:', sorted.map(e => e.name)); // Alice, Bob, Carol, Dave, Eve
|
|
108
|
+
|
|
109
|
+
// --- Reset ---
|
|
110
|
+
|
|
111
|
+
sorting.reset();
|
|
112
|
+
console.log('After reset key:', sorting.key); // null
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ViewModel } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// ViewModel: addCleanup pattern for resource management
|
|
4
|
+
//
|
|
5
|
+
// addCleanup() registers teardown callbacks that fire on dispose().
|
|
6
|
+
// Use it for intervals, event listeners, or any resource that needs
|
|
7
|
+
// explicit cleanup. React hooks (useLocal, useSingleton) auto-call
|
|
8
|
+
// dispose() on unmount, so cleanups run automatically.
|
|
9
|
+
|
|
10
|
+
interface TimerState {
|
|
11
|
+
elapsed: number;
|
|
12
|
+
running: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class TimerViewModel extends ViewModel<TimerState> {
|
|
16
|
+
private intervalId?: ReturnType<typeof setInterval>;
|
|
17
|
+
|
|
18
|
+
start() {
|
|
19
|
+
if (this.state.running) return;
|
|
20
|
+
|
|
21
|
+
this.set({ running: true });
|
|
22
|
+
this.intervalId = setInterval(() => {
|
|
23
|
+
this.set(prev => ({ elapsed: prev.elapsed + 1 }));
|
|
24
|
+
}, 1000);
|
|
25
|
+
|
|
26
|
+
// addCleanup registers a teardown callback that fires on dispose()
|
|
27
|
+
// Safe to call multiple times — clearInterval on an already-cleared ID is a no-op
|
|
28
|
+
this.addCleanup(() => clearInterval(this.intervalId));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
stop() {
|
|
32
|
+
if (!this.state.running) return;
|
|
33
|
+
|
|
34
|
+
clearInterval(this.intervalId);
|
|
35
|
+
this.intervalId = undefined;
|
|
36
|
+
this.set({ running: false });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
reset() {
|
|
40
|
+
this.stop();
|
|
41
|
+
this.set({ elapsed: 0 });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Usage
|
|
46
|
+
const timer = new TimerViewModel({ elapsed: 0, running: false });
|
|
47
|
+
|
|
48
|
+
timer.subscribe((state) => {
|
|
49
|
+
console.log(`Timer: ${state.elapsed}s (${state.running ? 'running' : 'stopped'})`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
timer.start();
|
|
53
|
+
|
|
54
|
+
// Stop after 5 seconds
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
timer.stop();
|
|
57
|
+
timer.dispose();
|
|
58
|
+
}, 5000);
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Trackable, ViewModel, bindPublicMethods } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Trackable: Base class for custom reactive objects
|
|
4
|
+
//
|
|
5
|
+
// Provides the subscribable + disposable + auto-bind foundation that
|
|
6
|
+
// all composable helpers (Sorting, Selection, Feed, Pagination, Pending)
|
|
7
|
+
// are built on. Extend it to build your own reactive objects that
|
|
8
|
+
// integrate with ViewModel's auto-tracking system.
|
|
9
|
+
//
|
|
10
|
+
// What Trackable provides:
|
|
11
|
+
// - subscribe() / notify() for change notifications
|
|
12
|
+
// - dispose() / disposeSignal / addCleanup() for lifecycle
|
|
13
|
+
// - Auto-bound public methods (point-free callbacks)
|
|
14
|
+
//
|
|
15
|
+
// What Trackable does NOT provide:
|
|
16
|
+
// - State management (no set(), no state getter)
|
|
17
|
+
// - Computed getters / memoization
|
|
18
|
+
// - Async tracking (no vm.async)
|
|
19
|
+
// - init() lifecycle (implement Initializable yourself if needed)
|
|
20
|
+
|
|
21
|
+
// --- Example 1: Custom RPC query wrapper ---
|
|
22
|
+
|
|
23
|
+
interface RPCResponse<T> {
|
|
24
|
+
data: T;
|
|
25
|
+
success: boolean;
|
|
26
|
+
code: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class RPCQuery<Args, Data> extends Trackable {
|
|
30
|
+
private _data: Data | undefined = undefined;
|
|
31
|
+
private _loading = false;
|
|
32
|
+
private _error: string | null = null;
|
|
33
|
+
private _callCounter = 0;
|
|
34
|
+
|
|
35
|
+
// Computed getters — auto-tracked when read inside ViewModel getters
|
|
36
|
+
get data(): Data | undefined { return this._data; }
|
|
37
|
+
get loading(): boolean { return this._loading; }
|
|
38
|
+
get error(): string | null { return this._error; }
|
|
39
|
+
|
|
40
|
+
constructor(private _endpoint: string) {
|
|
41
|
+
super();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async call(args?: Args): Promise<RPCResponse<Data>> {
|
|
45
|
+
if (this.disposed) throw new Error('RPCQuery: call() after dispose');
|
|
46
|
+
|
|
47
|
+
const callId = ++this._callCounter;
|
|
48
|
+
this._loading = true;
|
|
49
|
+
this._error = null;
|
|
50
|
+
this.notify();
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// Simulate an RPC call
|
|
54
|
+
const response = await simulateRPC<Data>(this._endpoint, args);
|
|
55
|
+
|
|
56
|
+
// Stale-call guard: only apply if this is still the latest call
|
|
57
|
+
if (!this.disposed && callId === this._callCounter) {
|
|
58
|
+
this._data = response.data;
|
|
59
|
+
this._loading = false;
|
|
60
|
+
this._error = null;
|
|
61
|
+
this.notify();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return response;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (!this.disposed && callId === this._callCounter) {
|
|
67
|
+
this._loading = false;
|
|
68
|
+
this._error = (err as Error).message;
|
|
69
|
+
this.notify();
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
clear() {
|
|
76
|
+
this._data = undefined;
|
|
77
|
+
this._error = null;
|
|
78
|
+
this.notify();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Simulate RPC
|
|
83
|
+
async function simulateRPC<T>(_endpoint: string, _args?: unknown): Promise<RPCResponse<T>> {
|
|
84
|
+
await new Promise(r => setTimeout(r, 100));
|
|
85
|
+
return { data: [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }] as T, success: true, code: 200 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Basic usage ---
|
|
89
|
+
|
|
90
|
+
const query = new RPCQuery<{ search: string }, { id: string; name: string }[]>('Users.Search');
|
|
91
|
+
|
|
92
|
+
// Subscribe to changes
|
|
93
|
+
query.subscribe(() => {
|
|
94
|
+
console.log(`Query state: loading=${query.loading}, data=${query.data?.length ?? 0} items, error=${query.error}`);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
console.log('Loading:', query.loading); // false
|
|
98
|
+
console.log('Data:', query.data); // undefined
|
|
99
|
+
|
|
100
|
+
await query.call({ search: 'alice' });
|
|
101
|
+
// Query state: loading=true, data=0 items, error=null
|
|
102
|
+
// Query state: loading=false, data=2 items, error=null
|
|
103
|
+
|
|
104
|
+
console.log('Data after call:', query.data); // [{ id: '1', name: 'Alice' }, ...]
|
|
105
|
+
|
|
106
|
+
// --- Method binding (point-free) ---
|
|
107
|
+
|
|
108
|
+
const { call, clear, dispose } = query;
|
|
109
|
+
// These work without wrapper functions — auto-bound by Trackable
|
|
110
|
+
clear(); // Resets data
|
|
111
|
+
|
|
112
|
+
// --- Dispose lifecycle ---
|
|
113
|
+
|
|
114
|
+
console.log('Disposed:', query.disposed); // false
|
|
115
|
+
console.log('Signal aborted:', query.disposeSignal.aborted); // false
|
|
116
|
+
|
|
117
|
+
dispose();
|
|
118
|
+
|
|
119
|
+
console.log('Disposed:', query.disposed); // true
|
|
120
|
+
console.log('Signal aborted:', query.disposeSignal.aborted); // true
|
|
121
|
+
|
|
122
|
+
// --- Example 2: ViewModel auto-tracking integration ---
|
|
123
|
+
|
|
124
|
+
interface User {
|
|
125
|
+
id: string;
|
|
126
|
+
name: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
class UsersViewModel extends ViewModel {
|
|
130
|
+
// RPCQuery as a property — auto-tracked by ViewModel's dependency system
|
|
131
|
+
readonly users = new RPCQuery<void, User[]>('Users.List');
|
|
132
|
+
|
|
133
|
+
// This getter auto-invalidates when users.notify() fires
|
|
134
|
+
get userCount(): number {
|
|
135
|
+
return this.users.data?.length ?? 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get isLoading(): boolean {
|
|
139
|
+
return this.users.loading;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get userNames(): string[] {
|
|
143
|
+
return (this.users.data ?? []).map(u => u.name);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async onInit() {
|
|
147
|
+
await this.users.call();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const vm = new UsersViewModel();
|
|
152
|
+
vm.init();
|
|
153
|
+
|
|
154
|
+
// After init, the RPCQuery fires and getters update automatically
|
|
155
|
+
await new Promise(r => setTimeout(r, 200));
|
|
156
|
+
|
|
157
|
+
console.log('User count:', vm.userCount); // 2
|
|
158
|
+
console.log('User names:', vm.userNames); // ['Alice', 'Bob']
|
|
159
|
+
|
|
160
|
+
vm.dispose();
|
|
161
|
+
|
|
162
|
+
// --- Example 3: addCleanup for external subscriptions ---
|
|
163
|
+
|
|
164
|
+
class LivePrice extends Trackable {
|
|
165
|
+
private _price = 0;
|
|
166
|
+
|
|
167
|
+
get price(): number { return this._price; }
|
|
168
|
+
|
|
169
|
+
constructor(symbol: string) {
|
|
170
|
+
super();
|
|
171
|
+
|
|
172
|
+
// Simulate a WebSocket price subscription
|
|
173
|
+
const interval = setInterval(() => {
|
|
174
|
+
this._price = Math.round(Math.random() * 10000) / 100;
|
|
175
|
+
this.notify();
|
|
176
|
+
}, 1000);
|
|
177
|
+
|
|
178
|
+
// addCleanup runs on dispose — auto-cleanup
|
|
179
|
+
this.addCleanup(() => {
|
|
180
|
+
clearInterval(interval);
|
|
181
|
+
console.log(`Unsubscribed from ${symbol} price feed`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
protected onDispose() {
|
|
186
|
+
console.log('LivePrice disposed');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const btcPrice = new LivePrice('BTC');
|
|
191
|
+
|
|
192
|
+
const unsub = btcPrice.subscribe(() => {
|
|
193
|
+
console.log(`BTC: $${btcPrice.price}`);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Let it tick a couple times
|
|
197
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
198
|
+
|
|
199
|
+
btcPrice.dispose(); // Clears interval, logs "Unsubscribed...", logs "LivePrice disposed"
|
|
200
|
+
unsub(); // Safe to call after dispose — no-op
|
|
201
|
+
|
|
202
|
+
// --- Example 4: bindPublicMethods standalone utility ---
|
|
203
|
+
//
|
|
204
|
+
// For classes that don't extend Trackable but still want point-free methods.
|
|
205
|
+
|
|
206
|
+
class Formatter {
|
|
207
|
+
constructor(private locale: string) {
|
|
208
|
+
bindPublicMethods(this);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
formatCurrency(amount: number): string {
|
|
212
|
+
return new Intl.NumberFormat(this.locale, { style: 'currency', currency: 'USD' }).format(amount);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
formatDate(date: Date): string {
|
|
216
|
+
return new Intl.DateTimeFormat(this.locale).format(date);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const fmt = new Formatter('en-US');
|
|
221
|
+
|
|
222
|
+
// Destructured methods work — bound by bindPublicMethods
|
|
223
|
+
const { formatCurrency, formatDate } = fmt;
|
|
224
|
+
console.log(formatCurrency(1234.56)); // $1,234.56
|
|
225
|
+
console.log(formatDate(new Date(2025, 0, 1))); // 1/1/2025
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"erasableSyntaxOnly": false,
|
|
12
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
13
|
+
"types": [],
|
|
14
|
+
"baseUrl": ".",
|
|
15
|
+
"paths": {
|
|
16
|
+
"mvc-kit": ["../../src/index.ts"]
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"include": ["."]
|
|
20
|
+
}
|