mvc-kit 2.13.0 → 2.13.2
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 +8 -3
- package/agent-config/claude-code/skills/{guide → mvc-kit}/SKILL.md +10 -1
- package/agent-config/lib/install-claude.mjs +39 -110
- 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
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/anti-patterns.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/api-reference.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/patterns.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/recipes.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/testing.md +0 -0
- /package/agent-config/claude-code/skills/{review → mvc-kit-review}/SKILL.md +0 -0
- /package/agent-config/claude-code/skills/{review → mvc-kit-review}/checklist.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/SKILL.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/channel.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/collection.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/controller.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/eventbus.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/model.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/page-component.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/persistent-collection.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/resource.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/service.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/viewmodel.md +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Pending, Resource, singleton, teardownAll } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Pending: Per-item operation queue with retry and status tracking
|
|
4
|
+
//
|
|
5
|
+
// A subscribable helper that manages async operations per item ID,
|
|
6
|
+
// with automatic exponential backoff retries for transient errors.
|
|
7
|
+
// Designed to live on a singleton Resource (NOT on a ViewModel).
|
|
8
|
+
//
|
|
9
|
+
// Typical use case: sending messages, submitting forms, processing
|
|
10
|
+
// items where each operation needs individual retry/cancel controls.
|
|
11
|
+
|
|
12
|
+
// --- Entity types ---
|
|
13
|
+
|
|
14
|
+
interface Message {
|
|
15
|
+
id: string;
|
|
16
|
+
text: string;
|
|
17
|
+
senderId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Resource with Pending ---
|
|
21
|
+
|
|
22
|
+
class MessagesResource extends Resource<Message> {
|
|
23
|
+
// Pending lives on the Resource so it survives ViewModel unmounts
|
|
24
|
+
readonly sending = new Pending<string>();
|
|
25
|
+
|
|
26
|
+
async send(message: Message) {
|
|
27
|
+
// enqueue: fire-and-forget with automatic retry on failure
|
|
28
|
+
this.sending.enqueue(
|
|
29
|
+
message.id, // unique key for this operation
|
|
30
|
+
'send', // operation name (for display/debugging)
|
|
31
|
+
async () => { // the execute callback
|
|
32
|
+
await fakeSend(message);
|
|
33
|
+
this.add(message); // add to collection on success
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
protected onDispose() {
|
|
39
|
+
this.sending.dispose();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Basic Pending usage (standalone) ---
|
|
44
|
+
|
|
45
|
+
const pending = new Pending<string>();
|
|
46
|
+
|
|
47
|
+
// Subscribe to status changes
|
|
48
|
+
pending.subscribe(() => {
|
|
49
|
+
console.log(`Pending: ${pending.count} operations, failed=${pending.failedCount}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Enqueue an operation
|
|
53
|
+
pending.enqueue(
|
|
54
|
+
'task-1',
|
|
55
|
+
'upload',
|
|
56
|
+
async () => {
|
|
57
|
+
// Simulated async work
|
|
58
|
+
await delay(100);
|
|
59
|
+
console.log('Upload complete!');
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
console.log('Has pending:', pending.hasPending); // true
|
|
64
|
+
|
|
65
|
+
// Check individual status
|
|
66
|
+
const status = pending.getStatus('task-1');
|
|
67
|
+
console.log('Status:', status?.status); // 'active'
|
|
68
|
+
console.log('Attempts:', status?.attempts); // 1
|
|
69
|
+
|
|
70
|
+
// Wait for completion
|
|
71
|
+
await delay(200);
|
|
72
|
+
console.log('After completion count:', pending.count); // 0 (removed on success)
|
|
73
|
+
|
|
74
|
+
// --- Retry behavior ---
|
|
75
|
+
|
|
76
|
+
let failCount = 0;
|
|
77
|
+
const retryPending = new Pending<string>();
|
|
78
|
+
|
|
79
|
+
retryPending.subscribe(() => {
|
|
80
|
+
const entry = retryPending.getStatus('flaky-op');
|
|
81
|
+
if (entry) {
|
|
82
|
+
console.log(`Flaky op: status=${entry.status}, attempts=${entry.attempts}`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
retryPending.enqueue(
|
|
87
|
+
'flaky-op',
|
|
88
|
+
'sync',
|
|
89
|
+
async () => {
|
|
90
|
+
failCount++;
|
|
91
|
+
if (failCount < 3) {
|
|
92
|
+
// Transient errors (network, timeout, server_error) trigger retries
|
|
93
|
+
const err = new Error('Connection failed');
|
|
94
|
+
(err as any).code = 'network';
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
console.log('Flaky operation succeeded on attempt', failCount);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Wait for retries to complete
|
|
102
|
+
await delay(5000);
|
|
103
|
+
|
|
104
|
+
// --- Manual controls ---
|
|
105
|
+
|
|
106
|
+
const controlPending = new Pending<string>();
|
|
107
|
+
|
|
108
|
+
// Enqueue a long-running operation
|
|
109
|
+
controlPending.enqueue(
|
|
110
|
+
'slow-task',
|
|
111
|
+
'process',
|
|
112
|
+
async () => {
|
|
113
|
+
await delay(10000); // very slow
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Cancel an in-flight operation
|
|
118
|
+
controlPending.cancel('slow-task');
|
|
119
|
+
console.log('After cancel:', controlPending.has('slow-task')); // false
|
|
120
|
+
|
|
121
|
+
// Enqueue one that will fail
|
|
122
|
+
controlPending.enqueue(
|
|
123
|
+
'will-fail',
|
|
124
|
+
'doomed',
|
|
125
|
+
async () => {
|
|
126
|
+
const err = new Error('Fatal error');
|
|
127
|
+
(err as any).code = 'validation'; // non-retryable error code
|
|
128
|
+
throw err;
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await delay(200);
|
|
133
|
+
|
|
134
|
+
console.log('Has failed:', controlPending.hasFailed); // true
|
|
135
|
+
console.log('Failed count:', controlPending.failedCount); // 1
|
|
136
|
+
|
|
137
|
+
// Dismiss a failed operation (remove without retrying)
|
|
138
|
+
controlPending.dismiss('will-fail');
|
|
139
|
+
console.log('After dismiss:', controlPending.count); // 0
|
|
140
|
+
|
|
141
|
+
// --- Entries (iterate all operations) ---
|
|
142
|
+
|
|
143
|
+
const listPending = new Pending<string>();
|
|
144
|
+
|
|
145
|
+
listPending.enqueue('a', 'upload', () => delay(500));
|
|
146
|
+
listPending.enqueue('b', 'upload', () => delay(500));
|
|
147
|
+
listPending.enqueue('c', 'upload', () => delay(500));
|
|
148
|
+
|
|
149
|
+
console.log('All entries:', listPending.entries.map(e => e.id)); // ['a', 'b', 'c']
|
|
150
|
+
|
|
151
|
+
// Cancel all at once
|
|
152
|
+
listPending.cancelAll();
|
|
153
|
+
console.log('After cancelAll:', listPending.count); // 0
|
|
154
|
+
|
|
155
|
+
// --- Resource integration pattern ---
|
|
156
|
+
|
|
157
|
+
const resource = singleton(MessagesResource);
|
|
158
|
+
await resource.init();
|
|
159
|
+
|
|
160
|
+
// Send messages — each gets independent retry/cancel
|
|
161
|
+
resource.send({ id: 'msg-1', text: 'Hello!', senderId: 'user-1' });
|
|
162
|
+
resource.send({ id: 'msg-2', text: 'How are you?', senderId: 'user-1' });
|
|
163
|
+
|
|
164
|
+
console.log('Sending count:', resource.sending.count); // 2
|
|
165
|
+
console.log('msg-1 status:', resource.sending.getStatus('msg-1')?.status); // 'active'
|
|
166
|
+
|
|
167
|
+
await delay(200);
|
|
168
|
+
|
|
169
|
+
console.log('Messages in collection:', resource.length); // 2 (both sent)
|
|
170
|
+
console.log('Sending count after:', resource.sending.count); // 0
|
|
171
|
+
|
|
172
|
+
// Cleanup
|
|
173
|
+
pending.dispose();
|
|
174
|
+
retryPending.dispose();
|
|
175
|
+
controlPending.dispose();
|
|
176
|
+
listPending.dispose();
|
|
177
|
+
resource.dispose();
|
|
178
|
+
teardownAll();
|
|
179
|
+
|
|
180
|
+
// --- Helpers ---
|
|
181
|
+
|
|
182
|
+
function delay(ms: number): Promise<void> {
|
|
183
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function fakeSend(message: Message): Promise<void> {
|
|
187
|
+
await delay(100);
|
|
188
|
+
console.log(`Sent: "${message.text}"`);
|
|
189
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { PersistentCollection } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// PersistentCollection: Collection + automatic storage persistence
|
|
4
|
+
//
|
|
5
|
+
// Abstract base class that extends Collection with delta tracking,
|
|
6
|
+
// debounced writes, and hydration. Subclass it and implement the
|
|
7
|
+
// persist* methods to connect to your storage backend.
|
|
8
|
+
//
|
|
9
|
+
// Concrete adapters are provided for common platforms:
|
|
10
|
+
// - WebStorageCollection (mvc-kit/web) — localStorage/sessionStorage
|
|
11
|
+
// - IndexedDBCollection (mvc-kit/web) — IndexedDB per-item storage
|
|
12
|
+
// - NativeCollection (mvc-kit/react-native) — configurable backend
|
|
13
|
+
//
|
|
14
|
+
// This example shows the abstract contract. See the platform-specific
|
|
15
|
+
// adapters for ready-to-use implementations.
|
|
16
|
+
|
|
17
|
+
// --- Entity type ---
|
|
18
|
+
|
|
19
|
+
interface CartItem {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
quantity: number;
|
|
23
|
+
price: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- Custom PersistentCollection (in-memory storage for demo) ---
|
|
27
|
+
|
|
28
|
+
// In-memory store simulating a storage backend
|
|
29
|
+
const fakeStorage = new Map<string, string>();
|
|
30
|
+
|
|
31
|
+
class CartCollection extends PersistentCollection<CartItem> {
|
|
32
|
+
// Unique key for this collection in storage
|
|
33
|
+
protected readonly storageKey = 'cart';
|
|
34
|
+
|
|
35
|
+
// --- Implement the abstract persist methods ---
|
|
36
|
+
|
|
37
|
+
protected persistGet(id: string): CartItem | null {
|
|
38
|
+
const raw = fakeStorage.get(this.storageKey);
|
|
39
|
+
if (!raw) return null;
|
|
40
|
+
const items: CartItem[] = this.deserialize(raw);
|
|
41
|
+
return items.find(i => i.id === id) ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
protected persistGetAll(): CartItem[] {
|
|
45
|
+
const raw = fakeStorage.get(this.storageKey);
|
|
46
|
+
return raw ? this.deserialize(raw) : [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
protected persistSet(items: CartItem[]): void {
|
|
50
|
+
// Merge new/updated items into existing storage
|
|
51
|
+
const existing = this.persistGetAll();
|
|
52
|
+
const map = new Map(existing.map(i => [i.id, i]));
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
map.set(item.id, item);
|
|
55
|
+
}
|
|
56
|
+
fakeStorage.set(this.storageKey, this.serialize([...map.values()]));
|
|
57
|
+
console.log('[Storage] Saved', map.size, 'items');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
protected persistRemove(ids: string[]): void {
|
|
61
|
+
const existing = this.persistGetAll();
|
|
62
|
+
const filtered = existing.filter(i => !ids.includes(i.id));
|
|
63
|
+
fakeStorage.set(this.storageKey, this.serialize(filtered));
|
|
64
|
+
console.log('[Storage] Removed', ids.length, 'items');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
protected persistClear(): void {
|
|
68
|
+
fakeStorage.delete(this.storageKey);
|
|
69
|
+
console.log('[Storage] Cleared');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Optional: custom error handling for storage failures
|
|
73
|
+
protected onPersistError(error: unknown) {
|
|
74
|
+
console.error('[Storage] Persist failed:', error);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Usage ---
|
|
79
|
+
|
|
80
|
+
const cart = new CartCollection();
|
|
81
|
+
|
|
82
|
+
// hydrate() loads data from storage into the collection.
|
|
83
|
+
// Idempotent — safe to call multiple times.
|
|
84
|
+
// For sync adapters (like WebStorageCollection), hydration is automatic.
|
|
85
|
+
// For async adapters (IndexedDB, NativeCollection), call hydrate() manually.
|
|
86
|
+
await cart.hydrate();
|
|
87
|
+
|
|
88
|
+
console.log('Hydrated:', cart.hydrated); // true
|
|
89
|
+
console.log('Items after hydrate:', cart.length); // 0 (empty storage)
|
|
90
|
+
|
|
91
|
+
// Subscribe to changes
|
|
92
|
+
cart.subscribe((items) => {
|
|
93
|
+
console.log(`Cart: ${items.length} items, total $${items.reduce((s, i) => s + i.price * i.quantity, 0).toFixed(2)}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// All Collection mutations automatically persist to storage
|
|
97
|
+
cart.add(
|
|
98
|
+
{ id: '1', name: 'Widget', quantity: 2, price: 9.99 },
|
|
99
|
+
{ id: '2', name: 'Gizmo', quantity: 1, price: 24.99 },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Verify storage was written
|
|
103
|
+
console.log('Storage contents:', fakeStorage.get('cart'));
|
|
104
|
+
|
|
105
|
+
// Update persists the change
|
|
106
|
+
cart.update('1', { quantity: 5 });
|
|
107
|
+
|
|
108
|
+
// Remove persists the deletion
|
|
109
|
+
cart.remove('2');
|
|
110
|
+
|
|
111
|
+
// clearStorage() removes from storage AND clears in-memory
|
|
112
|
+
cart.clearStorage();
|
|
113
|
+
console.log('After clearStorage:', cart.length); // 0
|
|
114
|
+
|
|
115
|
+
// Cleanup — flushes any pending writes before disposing
|
|
116
|
+
cart.dispose();
|
|
@@ -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);
|