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,162 @@
|
|
|
1
|
+
import { Feed } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Feed: Cursor-based pagination for infinite scroll / load-more
|
|
4
|
+
//
|
|
5
|
+
// A subscribable helper that accumulates items across pages using
|
|
6
|
+
// cursor-based server pagination. Tracks cursor position, hasMore
|
|
7
|
+
// status, and the growing item list.
|
|
8
|
+
//
|
|
9
|
+
// Designed to be a property on a ViewModel — auto-tracked so
|
|
10
|
+
// ViewModel getters that read feed state recompute automatically.
|
|
11
|
+
//
|
|
12
|
+
// Typical flow:
|
|
13
|
+
// 1. ViewModel calls API with feed.cursor
|
|
14
|
+
// 2. API returns { items, hasMore, cursor }
|
|
15
|
+
// 3. ViewModel calls feed.appendPage(result)
|
|
16
|
+
// 4. Repeat until hasMore is false
|
|
17
|
+
|
|
18
|
+
// --- Entity type ---
|
|
19
|
+
|
|
20
|
+
interface Post {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- Simulated API ---
|
|
27
|
+
|
|
28
|
+
function fakeFetchPosts(cursor: string | null): {
|
|
29
|
+
items: Post[];
|
|
30
|
+
hasMore: boolean;
|
|
31
|
+
cursor: string;
|
|
32
|
+
} {
|
|
33
|
+
const pages: Record<string, { items: Post[]; hasMore: boolean; cursor: string }> = {
|
|
34
|
+
initial: {
|
|
35
|
+
items: [
|
|
36
|
+
{ id: '1', title: 'First post', createdAt: '2024-01-01' },
|
|
37
|
+
{ id: '2', title: 'Second post', createdAt: '2024-01-02' },
|
|
38
|
+
{ id: '3', title: 'Third post', createdAt: '2024-01-03' },
|
|
39
|
+
],
|
|
40
|
+
hasMore: true,
|
|
41
|
+
cursor: 'page2',
|
|
42
|
+
},
|
|
43
|
+
page2: {
|
|
44
|
+
items: [
|
|
45
|
+
{ id: '4', title: 'Fourth post', createdAt: '2024-01-04' },
|
|
46
|
+
{ id: '5', title: 'Fifth post', createdAt: '2024-01-05' },
|
|
47
|
+
],
|
|
48
|
+
hasMore: true,
|
|
49
|
+
cursor: 'page3',
|
|
50
|
+
},
|
|
51
|
+
page3: {
|
|
52
|
+
items: [
|
|
53
|
+
{ id: '6', title: 'Sixth post', createdAt: '2024-01-06' },
|
|
54
|
+
],
|
|
55
|
+
hasMore: false,
|
|
56
|
+
cursor: 'end',
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const key = cursor ?? 'initial';
|
|
61
|
+
return pages[key]!;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Basic usage ---
|
|
65
|
+
|
|
66
|
+
const feed = new Feed<Post>();
|
|
67
|
+
|
|
68
|
+
// Subscribe to feed state changes
|
|
69
|
+
feed.subscribe(() => {
|
|
70
|
+
console.log(`Feed: ${feed.count} items, hasMore=${feed.hasMore}, cursor=${feed.cursor}`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Initial state
|
|
74
|
+
console.log('Cursor:', feed.cursor); // null
|
|
75
|
+
console.log('Has more:', feed.hasMore); // true
|
|
76
|
+
console.log('Items:', feed.count); // 0
|
|
77
|
+
|
|
78
|
+
// --- Load first page ---
|
|
79
|
+
|
|
80
|
+
const page1 = fakeFetchPosts(feed.cursor);
|
|
81
|
+
feed.appendPage(page1);
|
|
82
|
+
|
|
83
|
+
console.log('After page 1:', feed.count); // 3
|
|
84
|
+
console.log('Cursor:', feed.cursor); // 'page2'
|
|
85
|
+
console.log('Has more:', feed.hasMore); // true
|
|
86
|
+
|
|
87
|
+
// --- Load next page (items accumulate) ---
|
|
88
|
+
|
|
89
|
+
const page2 = fakeFetchPosts(feed.cursor);
|
|
90
|
+
feed.appendPage(page2);
|
|
91
|
+
|
|
92
|
+
console.log('After page 2:', feed.count); // 5
|
|
93
|
+
console.log('Cursor:', feed.cursor); // 'page3'
|
|
94
|
+
|
|
95
|
+
// --- Load final page ---
|
|
96
|
+
|
|
97
|
+
const page3 = fakeFetchPosts(feed.cursor);
|
|
98
|
+
feed.appendPage(page3);
|
|
99
|
+
|
|
100
|
+
console.log('After page 3:', feed.count); // 6
|
|
101
|
+
console.log('Has more:', feed.hasMore); // false (no more pages)
|
|
102
|
+
|
|
103
|
+
// Access all accumulated items
|
|
104
|
+
console.log('All items:', feed.items.map(p => p.title));
|
|
105
|
+
|
|
106
|
+
// --- Prepend (for chat UIs / newest-first feeds) ---
|
|
107
|
+
|
|
108
|
+
const chatFeed = new Feed<Post>();
|
|
109
|
+
|
|
110
|
+
chatFeed.appendPage({
|
|
111
|
+
items: [
|
|
112
|
+
{ id: 'c1', title: 'Latest message', createdAt: '2024-01-10' },
|
|
113
|
+
{ id: 'c2', title: 'Previous message', createdAt: '2024-01-09' },
|
|
114
|
+
],
|
|
115
|
+
hasMore: true,
|
|
116
|
+
cursor: 'older',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Prepend older messages at the top (e.g., scrolling up in a chat)
|
|
120
|
+
chatFeed.prependPage({
|
|
121
|
+
items: [
|
|
122
|
+
{ id: 'c3', title: 'Oldest message', createdAt: '2024-01-08' },
|
|
123
|
+
],
|
|
124
|
+
hasMore: false,
|
|
125
|
+
cursor: 'start',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
console.log('Chat order:', chatFeed.items.map(p => p.title));
|
|
129
|
+
// ['Oldest message', 'Latest message', 'Previous message']
|
|
130
|
+
|
|
131
|
+
// --- Push items without affecting cursor ---
|
|
132
|
+
|
|
133
|
+
feed.push({ id: '7', title: 'Optimistic post', createdAt: '2024-01-07' });
|
|
134
|
+
console.log('After push:', feed.count); // 7
|
|
135
|
+
|
|
136
|
+
// --- Filter items ---
|
|
137
|
+
|
|
138
|
+
feed.filter(p => p.id !== '7'); // remove the optimistic post
|
|
139
|
+
console.log('After filter:', feed.count); // 6
|
|
140
|
+
|
|
141
|
+
// --- Pull-to-refresh (replacePage) ---
|
|
142
|
+
|
|
143
|
+
feed.replacePage({
|
|
144
|
+
items: [
|
|
145
|
+
{ id: 'r1', title: 'Refreshed post 1', createdAt: '2024-02-01' },
|
|
146
|
+
{ id: 'r2', title: 'Refreshed post 2', createdAt: '2024-02-02' },
|
|
147
|
+
],
|
|
148
|
+
hasMore: true,
|
|
149
|
+
cursor: 'refreshed-page2',
|
|
150
|
+
});
|
|
151
|
+
console.log('After replacePage:', feed.count); // 2 (replaced all)
|
|
152
|
+
|
|
153
|
+
// --- setResult (update cursor/hasMore only) ---
|
|
154
|
+
|
|
155
|
+
feed.setResult({ hasMore: false, cursor: 'final' });
|
|
156
|
+
console.log('After setResult hasMore:', feed.hasMore); // false
|
|
157
|
+
console.log('Items unchanged:', feed.count); // 2
|
|
158
|
+
|
|
159
|
+
// --- Reset ---
|
|
160
|
+
|
|
161
|
+
feed.reset();
|
|
162
|
+
console.log('After reset:', feed.count, feed.cursor, feed.hasMore); // 0, null, true
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Model } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Model: Reactive entity with validation and dirty tracking
|
|
4
|
+
|
|
5
|
+
interface UserFormState {
|
|
6
|
+
name: string;
|
|
7
|
+
email: string;
|
|
8
|
+
age: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class UserFormModel extends Model<UserFormState> {
|
|
12
|
+
setName(name: string) {
|
|
13
|
+
this.set({ name });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setEmail(email: string) {
|
|
17
|
+
this.set({ email });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
setAge(age: number) {
|
|
21
|
+
this.set({ age });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Override to provide validation logic
|
|
25
|
+
protected validate(state: UserFormState) {
|
|
26
|
+
const errors: Partial<Record<keyof UserFormState, string>> = {};
|
|
27
|
+
|
|
28
|
+
if (!state.name.trim()) {
|
|
29
|
+
errors.name = 'Name is required';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!state.email.includes('@')) {
|
|
33
|
+
errors.email = 'Invalid email address';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (state.age < 0 || state.age > 150) {
|
|
37
|
+
errors.age = 'Age must be between 0 and 150';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return errors;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Usage ---
|
|
45
|
+
|
|
46
|
+
const form = new UserFormModel({ name: '', email: '', age: 0 });
|
|
47
|
+
|
|
48
|
+
// Subscribe to state changes
|
|
49
|
+
form.subscribe((state) => {
|
|
50
|
+
console.log('Form state:', state);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Check validation
|
|
54
|
+
console.log('Initial valid:', form.valid); // false
|
|
55
|
+
console.log('Initial errors:', form.errors); // { name: 'Name is required', email: 'Invalid email...' }
|
|
56
|
+
|
|
57
|
+
// Update fields
|
|
58
|
+
form.setName('John Doe');
|
|
59
|
+
form.setEmail('john@example.com');
|
|
60
|
+
form.setAge(30);
|
|
61
|
+
|
|
62
|
+
console.log('After updates valid:', form.valid); // true
|
|
63
|
+
console.log('After updates errors:', form.errors); // {}
|
|
64
|
+
|
|
65
|
+
// Dirty tracking - check if state differs from committed baseline
|
|
66
|
+
console.log('Is dirty:', form.dirty); // true (differs from initial state)
|
|
67
|
+
|
|
68
|
+
// Commit - mark current state as the new baseline
|
|
69
|
+
form.commit();
|
|
70
|
+
console.log('After commit dirty:', form.dirty); // false
|
|
71
|
+
|
|
72
|
+
// Make more changes
|
|
73
|
+
form.setName('Jane Doe');
|
|
74
|
+
console.log('After change dirty:', form.dirty); // true
|
|
75
|
+
|
|
76
|
+
// Rollback - revert to committed state
|
|
77
|
+
form.rollback();
|
|
78
|
+
console.log('After rollback name:', form.state.name); // 'John Doe'
|
|
79
|
+
console.log('After rollback dirty:', form.dirty); // false
|
|
80
|
+
|
|
81
|
+
// Cleanup
|
|
82
|
+
form.dispose();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Pagination } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Pagination: Page-based state with array slicing pipeline
|
|
4
|
+
//
|
|
5
|
+
// A subscribable helper that manages page/pageSize state and slices
|
|
6
|
+
// arrays accordingly. Designed to be a property on a ViewModel —
|
|
7
|
+
// auto-tracked so ViewModel getters that read pagination state
|
|
8
|
+
// recompute automatically.
|
|
9
|
+
|
|
10
|
+
// --- Sample data ---
|
|
11
|
+
|
|
12
|
+
const allItems = Array.from({ length: 47 }, (_, i) => ({
|
|
13
|
+
id: String(i + 1),
|
|
14
|
+
title: `Item ${i + 1}`,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// --- Basic usage ---
|
|
18
|
+
|
|
19
|
+
const pagination = new Pagination(); // default pageSize: 10
|
|
20
|
+
|
|
21
|
+
// Subscribe to page changes
|
|
22
|
+
pagination.subscribe(() => {
|
|
23
|
+
console.log(`Page ${pagination.page} of ${pagination.pageCount(allItems.length)}`);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Read current state
|
|
27
|
+
console.log('Page:', pagination.page); // 1
|
|
28
|
+
console.log('Page size:', pagination.pageSize); // 10
|
|
29
|
+
|
|
30
|
+
// --- Apply slice pipeline ---
|
|
31
|
+
|
|
32
|
+
const page1 = pagination.apply(allItems);
|
|
33
|
+
console.log('Page 1 items:', page1.length); // 10
|
|
34
|
+
console.log('First:', page1[0].title); // 'Item 1'
|
|
35
|
+
console.log('Last:', page1[9].title); // 'Item 10'
|
|
36
|
+
|
|
37
|
+
// --- Navigation ---
|
|
38
|
+
|
|
39
|
+
pagination.nextPage();
|
|
40
|
+
const page2 = pagination.apply(allItems);
|
|
41
|
+
console.log('Page 2 first:', page2[0].title); // 'Item 11'
|
|
42
|
+
|
|
43
|
+
pagination.nextPage();
|
|
44
|
+
pagination.nextPage();
|
|
45
|
+
pagination.nextPage(); // page 5 (last page)
|
|
46
|
+
|
|
47
|
+
const lastPage = pagination.apply(allItems);
|
|
48
|
+
console.log('Last page items:', lastPage.length); // 7 (47 - 40)
|
|
49
|
+
console.log('Last page first:', lastPage[0].title); // 'Item 41'
|
|
50
|
+
|
|
51
|
+
// --- Boundary checks ---
|
|
52
|
+
|
|
53
|
+
console.log('Page count:', pagination.pageCount(allItems.length)); // 5
|
|
54
|
+
console.log('Has next:', pagination.hasNext(allItems.length)); // false (on last page)
|
|
55
|
+
console.log('Has prev:', pagination.hasPrev()); // true
|
|
56
|
+
|
|
57
|
+
pagination.prevPage();
|
|
58
|
+
console.log('After prevPage:', pagination.page); // 4
|
|
59
|
+
|
|
60
|
+
// prevPage on page 1 is a no-op
|
|
61
|
+
pagination.setPage(1);
|
|
62
|
+
pagination.prevPage();
|
|
63
|
+
console.log('After prevPage on page 1:', pagination.page); // 1
|
|
64
|
+
|
|
65
|
+
// --- Change page size ---
|
|
66
|
+
|
|
67
|
+
pagination.setPageSize(20); // resets to page 1 automatically
|
|
68
|
+
console.log('New page size:', pagination.pageSize); // 20
|
|
69
|
+
console.log('Page after resize:', pagination.page); // 1
|
|
70
|
+
console.log('New page count:', pagination.pageCount(allItems.length)); // 3
|
|
71
|
+
|
|
72
|
+
const bigPage = pagination.apply(allItems);
|
|
73
|
+
console.log('Items on resized page:', bigPage.length); // 20
|
|
74
|
+
|
|
75
|
+
// --- Navigate to specific page ---
|
|
76
|
+
|
|
77
|
+
pagination.setPage(3);
|
|
78
|
+
const thirdPage = pagination.apply(allItems);
|
|
79
|
+
console.log('Page 3 items:', thirdPage.length); // 7 (47 - 40)
|
|
80
|
+
|
|
81
|
+
// --- Custom initial page size ---
|
|
82
|
+
|
|
83
|
+
const smallPages = new Pagination({ pageSize: 5 });
|
|
84
|
+
console.log('Custom page size:', smallPages.pageSize); // 5
|
|
85
|
+
console.log('Custom page count:', smallPages.pageCount(allItems.length)); // 10
|
|
86
|
+
|
|
87
|
+
// --- Reset ---
|
|
88
|
+
|
|
89
|
+
pagination.setPage(3);
|
|
90
|
+
pagination.reset(); // resets to page 1
|
|
91
|
+
console.log('After reset page:', pagination.page); // 1
|
|
@@ -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();
|