mvc-kit 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ # Controller Template: {{Name}}
2
+
3
+ ## {{Name}}Controller.ts
4
+
5
+ ```typescript
6
+ import { Controller } from 'mvc-kit';
7
+
8
+ export class {{Name}}Controller extends Controller {
9
+ constructor(
10
+ // TODO: Inject dependencies
11
+ // private viewModelA: SomeViewModel,
12
+ // private viewModelB: AnotherViewModel,
13
+ ) {
14
+ super();
15
+ }
16
+
17
+ protected onInit() {
18
+ // Wire up cross-ViewModel subscriptions
19
+ // this.subscribeTo(this.viewModelA, () => this.onViewModelAChanged());
20
+ }
21
+
22
+ // TODO: Add orchestration methods
23
+ // async submit() {
24
+ // const items = this.viewModelA.state.items;
25
+ // await this.viewModelB.process(items);
26
+ // }
27
+
28
+ // private onViewModelAChanged() {
29
+ // // React to changes in viewModelA
30
+ // }
31
+ }
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```tsx
37
+ import { useLocal } from 'mvc-kit/react';
38
+ import { {{Name}}Controller } from '../controllers/{{Name}}Controller';
39
+
40
+ function {{Name}}Page() {
41
+ const controller = useLocal(() => new {{Name}}Controller(/* deps */));
42
+
43
+ return (
44
+ <div>
45
+ <button onClick={() => controller.submit()}>Submit</button>
46
+ </div>
47
+ );
48
+ }
49
+ ```
50
+
51
+ ## Notes
52
+
53
+ - **Rare** — most orchestration fits in a single ViewModel
54
+ - Use only when coordinating **multiple ViewModels** in a single workflow
55
+ - Has `disposeSignal`, `subscribeTo`, `addCleanup` — but no state, no getters, no async tracking
56
+ - Component-scoped via `useLocal` with factory function
@@ -0,0 +1,54 @@
1
+ # EventBus Template: {{Name}}
2
+
3
+ ## {{Name}}EventBus.ts
4
+
5
+ ```typescript
6
+ import { EventBus } from 'mvc-kit';
7
+
8
+ export interface {{Name}}Events {
9
+ // TODO: Define your event map
10
+ // 'item:created': { id: string };
11
+ // 'item:deleted': { id: string };
12
+ // 'notification': { message: string; severity: 'success' | 'error' | 'info' };
13
+ }
14
+
15
+ export class {{Name}}EventBus extends EventBus<{{Name}}Events> {}
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```typescript
21
+ import { singleton } from 'mvc-kit';
22
+ import { {{Name}}EventBus } from '../events/{{Name}}EventBus';
23
+
24
+ // In a ViewModel:
25
+ private bus = singleton({{Name}}EventBus);
26
+
27
+ // Emit
28
+ this.bus.emit('item:created', { id: '123' });
29
+
30
+ // Subscribe (with auto-cleanup)
31
+ this.addCleanup(
32
+ this.bus.on('item:deleted', ({ id }) => {
33
+ this.set({ items: this.state.items.filter(i => i.id !== id) });
34
+ }),
35
+ );
36
+ ```
37
+
38
+ ```tsx
39
+ // In a React component:
40
+ import { useEvent } from 'mvc-kit/react';
41
+ import { singleton } from 'mvc-kit';
42
+ import { {{Name}}EventBus } from '../events/{{Name}}EventBus';
43
+
44
+ const bus = singleton({{Name}}EventBus);
45
+ useEvent(bus, 'notification', ({ message }) => {
46
+ toast.show(message);
47
+ });
48
+ ```
49
+
50
+ ## Notes
51
+
52
+ - Use EventBus for **cross-cutting** events between unrelated parts of the app
53
+ - For ViewModel-to-component events (toasts, navigation), prefer the **second generic parameter** on ViewModel instead
54
+ - Keep the event map small — past 10-15 events, concerns may be entangled
@@ -0,0 +1,102 @@
1
+ # Model Template: {{Name}}
2
+
3
+ ## {{Name}}Model.ts
4
+
5
+ ```typescript
6
+ import { Model } from 'mvc-kit';
7
+ import type { ValidationErrors } from 'mvc-kit';
8
+
9
+ export interface {{Name}}FormState {
10
+ name: string;
11
+ // TODO: Add your fields
12
+ }
13
+
14
+ export class {{Name}}Model extends Model<{{Name}}FormState> {
15
+ setName(name: string) { this.set({ name }); }
16
+ // TODO: Add setters for each field
17
+
18
+ protected validate(state: {{Name}}FormState): ValidationErrors<{{Name}}FormState> {
19
+ const errors: Partial<Record<keyof {{Name}}FormState, string>> = {};
20
+ if (!state.name.trim()) errors.name = 'Name is required';
21
+ // TODO: Add validation rules
22
+ return errors;
23
+ }
24
+ }
25
+ ```
26
+
27
+ ## {{Name}}Model.test.ts
28
+
29
+ ```typescript
30
+ import { describe, test, expect } from 'vitest';
31
+ import { {{Name}}Model } from './{{Name}}Model';
32
+
33
+ describe('{{Name}}Model', () => {
34
+ function create(overrides: Partial<{ name: string }> = {}) {
35
+ return new {{Name}}Model({
36
+ name: '',
37
+ ...overrides,
38
+ });
39
+ }
40
+
41
+ test('validates required name', () => {
42
+ const model = create();
43
+ expect(model.valid).toBe(false);
44
+ expect(model.errors.name).toBe('Name is required');
45
+ model.dispose();
46
+ });
47
+
48
+ test('valid when all fields filled', () => {
49
+ const model = create({ name: 'Test' });
50
+ expect(model.valid).toBe(true);
51
+ expect(model.errors.name).toBeUndefined();
52
+ model.dispose();
53
+ });
54
+
55
+ test('tracks dirty state', () => {
56
+ const model = create({ name: 'Original' });
57
+ expect(model.dirty).toBe(false);
58
+
59
+ model.setName('Changed');
60
+ expect(model.dirty).toBe(true);
61
+
62
+ model.rollback();
63
+ expect(model.state.name).toBe('Original');
64
+ expect(model.dirty).toBe(false);
65
+ model.dispose();
66
+ });
67
+
68
+ test('commit resets dirty baseline', () => {
69
+ const model = create({ name: 'Original' });
70
+ model.setName('Updated');
71
+ expect(model.dirty).toBe(true);
72
+
73
+ model.commit();
74
+ expect(model.dirty).toBe(false);
75
+ model.dispose();
76
+ });
77
+ });
78
+ ```
79
+
80
+ ## React Usage
81
+
82
+ ```tsx
83
+ import { useModel, useField } from 'mvc-kit/react';
84
+ import { {{Name}}Model } from '../models/{{Name}}Model';
85
+
86
+ function {{Name}}Form() {
87
+ const { state, errors, valid, dirty, model } = useModel(
88
+ () => new {{Name}}Model({ name: '' }),
89
+ );
90
+
91
+ return (
92
+ <form onSubmit={e => { e.preventDefault(); /* handle submit */ }}>
93
+ <input
94
+ value={state.name}
95
+ onChange={e => model.setName(e.target.value)}
96
+ />
97
+ {errors.name && <span className="error">{errors.name}</span>}
98
+ <button disabled={!valid || !dirty}>Save</button>
99
+ </form>
100
+ );
101
+ }
102
+ ```
@@ -0,0 +1,58 @@
1
+ # Page Component Template: {{Name}}
2
+
3
+ ## {{Name}}Page.tsx
4
+
5
+ ```tsx
6
+ import { useLocal } from 'mvc-kit/react';
7
+ import { {{Name}}ViewModel } from '../viewmodels/{{Name}}ViewModel';
8
+
9
+ export function {{Name}}Page() {
10
+ const [state, vm] = useLocal({{Name}}ViewModel, {
11
+ items: [],
12
+ search: '',
13
+ // TODO: Add initial state matching {{Name}}ViewModel's State interface
14
+ });
15
+ const { loading, error } = vm.async.load;
16
+
17
+ // Optional: subscribe to ViewModel events
18
+ // useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));
19
+
20
+ return (
21
+ <div>
22
+ <h1>{{Name}}</h1>
23
+
24
+ {/* Filters */}
25
+ <input
26
+ placeholder="Search..."
27
+ value={state.search}
28
+ onChange={e => vm.setSearch(e.target.value)}
29
+ />
30
+
31
+ {/* Loading/Error states from async tracking */}
32
+ {loading && <p>Loading...</p>}
33
+ {error && <p className="error">{error}</p>}
34
+
35
+ {/* Data — use vm.x for computed values */}
36
+ {!loading && (
37
+ <div>
38
+ {/* TODO: Replace with your data display */}
39
+ <p>Showing {vm.filtered.length} of {vm.total}</p>
40
+ <ul>
41
+ {vm.filtered.map((item: any) => (
42
+ <li key={item.id}>{JSON.stringify(item)}</li>
43
+ ))}
44
+ </ul>
45
+ </div>
46
+ )}
47
+ </div>
48
+ );
49
+ }
50
+ ```
51
+
52
+ ## Rules
53
+
54
+ - **One ViewModel per component** via `useLocal`
55
+ - **No `useEffect`** for data loading — `onInit()` handles it
56
+ - **No `useState`/`useMemo`/`useCallback`** — the ViewModel is the hook
57
+ - Read `state.x` for raw values, `vm.x` for computed, `vm.async.x` for loading/error
58
+ - Connected components own a ViewModel; extract presentational children that receive props
@@ -0,0 +1,101 @@
1
+ # Service Template: {{Name}}
2
+
3
+ ## {{Name}}Service.ts
4
+
5
+ ```typescript
6
+ import { Service, HttpError } from 'mvc-kit';
7
+
8
+ // TODO: Import or define your data types
9
+ // import type { {{Name}}Item } from '../types/{{name}}';
10
+
11
+ export class {{Name}}Service extends Service {
12
+ private baseUrl = '/api/{{name}}s'; // TODO: Set your API path
13
+
14
+ async getAll(signal?: AbortSignal): Promise<any[]> {
15
+ const res = await fetch(this.baseUrl, { signal });
16
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
17
+ return res.json();
18
+ }
19
+
20
+ async getById(id: string, signal?: AbortSignal): Promise<any> {
21
+ const res = await fetch(`${this.baseUrl}/${id}`, { signal });
22
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
23
+ return res.json();
24
+ }
25
+
26
+ async create(data: any, signal?: AbortSignal): Promise<any> {
27
+ const res = await fetch(this.baseUrl, {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/json' },
30
+ body: JSON.stringify(data),
31
+ signal,
32
+ });
33
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
34
+ return res.json();
35
+ }
36
+
37
+ async update(id: string, data: any, signal?: AbortSignal): Promise<any> {
38
+ const res = await fetch(`${this.baseUrl}/${id}`, {
39
+ method: 'PUT',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify(data),
42
+ signal,
43
+ });
44
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
45
+ return res.json();
46
+ }
47
+
48
+ async delete(id: string, signal?: AbortSignal): Promise<void> {
49
+ const res = await fetch(`${this.baseUrl}/${id}`, {
50
+ method: 'DELETE',
51
+ signal,
52
+ });
53
+ if (!res.ok) throw new HttpError(res.status, res.statusText);
54
+ }
55
+ }
56
+ ```
57
+
58
+ ## {{Name}}Service.test.ts
59
+
60
+ ```typescript
61
+ import { describe, test, expect, beforeEach, vi } from 'vitest';
62
+ import { teardownAll } from 'mvc-kit';
63
+ import { {{Name}}Service } from './{{Name}}Service';
64
+
65
+ beforeEach(() => teardownAll());
66
+
67
+ describe('{{Name}}Service', () => {
68
+ test('getAll fetches items', async () => {
69
+ const mockData = [{ id: '1' }];
70
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
71
+ new Response(JSON.stringify(mockData), { status: 200 }),
72
+ );
73
+
74
+ const service = new {{Name}}Service();
75
+ const result = await service.getAll();
76
+ expect(result).toEqual(mockData);
77
+
78
+ vi.restoreAllMocks();
79
+ service.dispose();
80
+ });
81
+
82
+ test('getAll throws HttpError on failure', async () => {
83
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
84
+ new Response(null, { status: 500, statusText: 'Internal Server Error' }),
85
+ );
86
+
87
+ const service = new {{Name}}Service();
88
+ await expect(service.getAll()).rejects.toThrow('Internal Server Error');
89
+
90
+ vi.restoreAllMocks();
91
+ service.dispose();
92
+ });
93
+ });
94
+ ```
95
+
96
+ ## Rules
97
+
98
+ - **Stateless** — no caching (that's a Collection's job)
99
+ - **Accept `AbortSignal`** — lets ViewModels cancel via `disposeSignal`
100
+ - **Throw `HttpError`** — carries HTTP status for `classifyError()` to produce canonical codes
101
+ - **No knowledge of ViewModels or Collections** — Services are at the bottom of the dependency graph
@@ -0,0 +1,128 @@
1
+ # ViewModel Template: {{Name}}
2
+
3
+ ## {{Name}}ViewModel.ts
4
+
5
+ ```typescript
6
+ import { ViewModel, singleton } from 'mvc-kit';
7
+ // import { {{Name}}Service } from '../services/{{Name}}Service';
8
+ // import { {{Name}}Collection } from '../collections/{{Name}}Collection';
9
+
10
+ interface {{Name}}State {
11
+ items: any[]; // TODO: Replace with your item type
12
+ search: string;
13
+ }
14
+
15
+ // Optional: define events for toasts, navigation, etc.
16
+ // interface {{Name}}Events {
17
+ // saved: { id: string };
18
+ // }
19
+
20
+ export class {{Name}}ViewModel extends ViewModel<{{Name}}State> {
21
+ // --- Private fields ---
22
+ // private service = singleton({{Name}}Service);
23
+ // private collection = singleton({{Name}}Collection);
24
+
25
+ // --- Computed getters ---
26
+ get filtered(): any[] {
27
+ const { items, search } = this.state;
28
+ if (!search) return items;
29
+ const q = search.toLowerCase();
30
+ return items.filter(item =>
31
+ JSON.stringify(item).toLowerCase().includes(q),
32
+ );
33
+ }
34
+
35
+ get total(): number {
36
+ return this.state.items.length;
37
+ }
38
+
39
+ get hasResults(): boolean {
40
+ return this.filtered.length > 0;
41
+ }
42
+
43
+ // --- Lifecycle ---
44
+ protected onInit() {
45
+ // Subscribe to collection and mirror data into state
46
+ // this.subscribeTo(this.collection, () => {
47
+ // this.set({ items: this.collection.items });
48
+ // });
49
+
50
+ // Smart init: use cached data or fetch fresh
51
+ // if (this.collection.length > 0) {
52
+ // this.set({ items: this.collection.items });
53
+ // } else {
54
+ // this.load();
55
+ // }
56
+
57
+ this.load();
58
+ }
59
+
60
+ // --- Actions ---
61
+ async load() {
62
+ // const data = await this.service.getAll(this.disposeSignal);
63
+ // this.collection.reset(data);
64
+ // TODO: implement data loading
65
+ }
66
+
67
+ // --- Setters ---
68
+ setSearch(search: string) { this.set({ search }); }
69
+ }
70
+ ```
71
+
72
+ ## {{Name}}ViewModel.test.ts
73
+
74
+ ```typescript
75
+ import { describe, test, expect, beforeEach } from 'vitest';
76
+ import { teardownAll } from 'mvc-kit';
77
+ import { {{Name}}ViewModel } from './{{Name}}ViewModel';
78
+
79
+ beforeEach(() => teardownAll());
80
+
81
+ describe('{{Name}}ViewModel', () => {
82
+ function create(overrides: Partial<{ items: any[]; search: string }> = {}) {
83
+ return new {{Name}}ViewModel({
84
+ items: [],
85
+ search: '',
86
+ ...overrides,
87
+ });
88
+ }
89
+
90
+ test('initializes with default state', () => {
91
+ const vm = create();
92
+ expect(vm.state.items).toEqual([]);
93
+ expect(vm.state.search).toBe('');
94
+ vm.dispose();
95
+ });
96
+
97
+ test('setSearch updates search state', () => {
98
+ const vm = create();
99
+ vm.setSearch('test');
100
+ expect(vm.state.search).toBe('test');
101
+ vm.dispose();
102
+ });
103
+
104
+ test('filtered getter applies search', () => {
105
+ const vm = create({
106
+ items: [
107
+ { id: '1', name: 'Alpha' },
108
+ { id: '2', name: 'Beta' },
109
+ ],
110
+ });
111
+
112
+ expect(vm.filtered).toHaveLength(2);
113
+
114
+ vm.setSearch('alpha');
115
+ expect(vm.filtered).toHaveLength(1);
116
+
117
+ vm.dispose();
118
+ });
119
+
120
+ test('total returns item count', () => {
121
+ const vm = create({
122
+ items: [{ id: '1' }, { id: '2' }, { id: '3' }],
123
+ });
124
+ expect(vm.total).toBe(3);
125
+ vm.dispose();
126
+ });
127
+ });
128
+ ```