moicle 1.3.1 → 1.6.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.
@@ -1,6 +1,6 @@
1
1
  # React Frontend Structure
2
2
 
3
- > MVVM (Model-View-ViewModel) architecture
3
+ > Module-based architecture with hooks + services. Idiomatic React — no MVVM ceremony.
4
4
 
5
5
  ## Project Structure
6
6
 
@@ -13,26 +13,21 @@
13
13
  │ │ ├── app.config.ts
14
14
  │ │ └── routes.config.ts
15
15
  │ ├── core/
16
- │ │ ├── mvvm/
17
- │ │ │ └── ViewModel.ts # ViewModel factory
18
- │ │ ├── context/ # Global contexts
19
- │ │ ├── hooks/ # Core hooks
20
- │ │ ├── interfaces/ # Core interfaces
21
- │ │ ├── enums/ # Core enums
16
+ │ │ ├── context/ # Global contexts (auth, theme)
17
+ │ │ ├── hooks/ # Core reusable hooks
22
18
  │ │ ├── errors/ # Error handling
23
19
  │ │ ├── utils/ # Core utilities
24
- │ │ ├── HttpClient.ts # API client
20
+ │ │ ├── http-client.ts # API client
25
21
  │ │ └── bootstrap.ts # App initialization
26
22
  │ ├── modules/
27
23
  │ │ └── {module}/
28
- │ │ ├── models/ # Types, interfaces, DTOs
29
- │ │ ├── view-models/ # Hooks (state management)
24
+ │ │ ├── types/ # Types, interfaces, DTOs, schemas
30
25
  │ │ ├── services/ # API calls
26
+ │ │ ├── hooks/ # Module hooks (state + data fetching)
31
27
  │ │ ├── components/ # Module components
32
28
  │ │ ├── pages/ # Page components
33
29
  │ │ └── index.ts
34
30
  │ ├── lib/ # Third-party integrations
35
- │ ├── services/ # Shared services
36
31
  │ ├── utils/ # Shared utilities
37
32
  │ ├── router.tsx # Route definitions
38
33
  │ ├── index.tsx # Entry point
@@ -46,56 +41,60 @@
46
41
  └── tailwind.config.js
47
42
  ```
48
43
 
49
- ## MVVM Pattern
44
+ ## Layering
50
45
 
51
46
  ```
52
47
  ┌─────────────────────────────────────────────────┐
53
- View
54
- (Pages + Components)
48
+ Pages / Components
49
+ (render UI)
55
50
  ├─────────────────────────────────────────────────┤
56
- ViewModel
57
- (Hooks - state & logic)
51
+ Hooks
52
+ (state, queries, mutations, business logic)
58
53
  ├─────────────────────────────────────────────────┤
59
- Model
60
- (Types + Services)
54
+ Services
55
+ (pure API calls)
56
+ ├─────────────────────────────────────────────────┤
57
+ │ Types │
58
+ │ (models, DTOs, Zod schemas) │
61
59
  └─────────────────────────────────────────────────┘
62
60
  ```
63
61
 
64
- **Data flow:**
65
- 1. View renders and uses ViewModel (hook)
66
- 2. ViewModel manages state and calls Services
67
- 3. Services make API calls
68
- 4. Model defines data types
62
+ **Rules:**
63
+ - Components consume hooks, not services directly
64
+ - Hooks orchestrate services + state (TanStack Query, Zustand, useState)
65
+ - Services are pure functions: input → API → output
66
+ - Types are the contract shared across all layers
69
67
 
70
68
  ## Module Structure
71
69
 
72
70
  ```
73
71
  modules/{module}/
74
- ├── models/
72
+ ├── types/
75
73
  │ ├── {module}.model.ts # Types & interfaces
76
74
  │ ├── {module}.schema.ts # Zod schemas (validation)
77
75
  │ └── index.ts
78
- ├── view-models/
79
- │ ├── use-{module}-list.ts # List ViewModel
80
- │ ├── use-{module}-form.ts # Form ViewModel (optional)
81
- │ └── index.ts
82
76
  ├── services/
83
77
  │ ├── {module}.service.ts # API functions
84
78
  │ └── index.ts
79
+ ├── hooks/
80
+ │ ├── use-{module}-list.ts # List query hook
81
+ │ ├── use-{module}-detail.ts # Detail query hook
82
+ │ ├── use-{module}-mutations.ts # Create/update/delete mutations
83
+ │ └── index.ts
85
84
  ├── components/
86
- │ ├── {Module}Table.tsx
87
- │ ├── {Module}Modal.tsx
85
+ │ ├── {module}-table.tsx
86
+ │ ├── {module}-form.tsx
88
87
  │ └── index.ts
89
88
  ├── pages/
90
- │ ├── {Module}ListPage.tsx
89
+ │ ├── {module}-list-page.tsx
91
90
  │ └── index.ts
92
91
  └── index.ts
93
92
  ```
94
93
 
95
94
  ## Key Files
96
95
 
97
- ### models/user.model.ts
98
- ```tsx
96
+ ### types/user.model.ts
97
+ ```ts
99
98
  export interface User {
100
99
  id: string;
101
100
  name: string;
@@ -110,8 +109,8 @@ export interface CreateUserRequest {
110
109
  password: string;
111
110
  }
112
111
 
113
- export interface UserListResponse {
114
- data: User[];
112
+ export interface Paginated<T> {
113
+ data: T[];
115
114
  meta: {
116
115
  currentPage: number;
117
116
  lastPage: number;
@@ -119,150 +118,222 @@ export interface UserListResponse {
119
118
  total: number;
120
119
  };
121
120
  }
121
+
122
+ export interface UserListParams {
123
+ page?: number;
124
+ pageSize?: number;
125
+ search?: string;
126
+ sort?: { field: keyof User; order: 'asc' | 'desc' };
127
+ }
128
+ ```
129
+
130
+ ### types/user.schema.ts
131
+ ```ts
132
+ import { z } from 'zod';
133
+
134
+ export const createUserSchema = z.object({
135
+ name: z.string().min(1, 'Name is required'),
136
+ email: z.string().email('Invalid email'),
137
+ password: z.string().min(8, 'Min 8 characters'),
138
+ });
139
+
140
+ export type CreateUserFormData = z.infer<typeof createUserSchema>;
122
141
  ```
123
142
 
124
143
  ### services/user.service.ts
125
- ```tsx
126
- import Client from '@/core/HttpClient';
127
- import { User, CreateUserRequest } from '../models/user.model';
144
+ ```ts
145
+ import { httpClient } from '@/core/http-client';
146
+ import type { User, CreateUserRequest, Paginated, UserListParams } from '../types/user.model';
128
147
 
129
- export const createUser = async (data: CreateUserRequest): Promise<User> => {
130
- return await Client.post<User>('/users', {
131
- body: JSON.stringify(data),
132
- });
148
+ export const userService = {
149
+ list: (params: UserListParams) =>
150
+ httpClient.get<Paginated<User>>('/users', { params }),
151
+
152
+ detail: (id: string) =>
153
+ httpClient.get<User>(`/users/${id}`),
154
+
155
+ create: (data: CreateUserRequest) =>
156
+ httpClient.post<User>('/users', data),
157
+
158
+ update: (id: string, data: Partial<CreateUserRequest>) =>
159
+ httpClient.patch<User>(`/users/${id}`, data),
160
+
161
+ remove: (id: string) =>
162
+ httpClient.delete<void>(`/users/${id}`),
163
+ };
164
+ ```
165
+
166
+ ### hooks/use-user-list.ts
167
+ ```ts
168
+ import { useQuery } from '@tanstack/react-query';
169
+ import { userService } from '../services/user.service';
170
+ import type { UserListParams } from '../types/user.model';
171
+
172
+ export const userKeys = {
173
+ all: ['users'] as const,
174
+ list: (params: UserListParams) => [...userKeys.all, 'list', params] as const,
175
+ detail: (id: string) => [...userKeys.all, 'detail', id] as const,
133
176
  };
134
177
 
135
- export const deleteUser = async (id: string): Promise<void> => {
136
- await Client.delete(`/users/${id}`);
178
+ export const useUserList = (params: UserListParams) => {
179
+ return useQuery({
180
+ queryKey: userKeys.list(params),
181
+ queryFn: () => userService.list(params),
182
+ placeholderData: (prev) => prev,
183
+ });
137
184
  };
138
185
  ```
139
186
 
140
- ### view-models/use-user-list.ts
141
- ```tsx
142
- import { User } from '../models/user.model';
143
- import { listViewModelFactory } from '@/core/mvvm/ViewModel';
144
-
145
- export const useUserList = () => {
146
- const viewModel = listViewModelFactory<User>(
147
- undefined, // viewModel config
148
- '/users', // API endpoint
149
- {}, // default filters
150
- { field: 'name', order: 'asc' } // default sorter
151
- );
187
+ ### hooks/use-user-mutations.ts
188
+ ```ts
189
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
190
+ import { userService } from '../services/user.service';
191
+ import { userKeys } from './use-user-list';
192
+ import type { CreateUserRequest } from '../types/user.model';
193
+
194
+ export const useCreateUser = () => {
195
+ const queryClient = useQueryClient();
196
+
197
+ return useMutation({
198
+ mutationFn: (data: CreateUserRequest) => userService.create(data),
199
+ onSuccess: () => {
200
+ queryClient.invalidateQueries({ queryKey: userKeys.all });
201
+ },
202
+ });
203
+ };
152
204
 
153
- return { ...viewModel };
205
+ export const useDeleteUser = () => {
206
+ const queryClient = useQueryClient();
207
+
208
+ return useMutation({
209
+ mutationFn: (id: string) => userService.remove(id),
210
+ onSuccess: () => {
211
+ queryClient.invalidateQueries({ queryKey: userKeys.all });
212
+ },
213
+ });
154
214
  };
155
215
  ```
156
216
 
157
- ### pages/UserListPage.tsx
217
+ ### pages/user-list-page.tsx
158
218
  ```tsx
159
219
  import { useState } from 'react';
160
- import { User } from '../models/user.model';
161
- import { useUserList } from '../view-models/use-user-list';
162
- import { UserTable } from '../components/UserTable';
163
- import { UserModal } from '../components/UserModal';
164
-
165
- export const UserListPage: React.FC = () => {
166
- const [modalOpen, setModalOpen] = useState(false);
167
- const [selectedUser, setSelectedUser] = useState<User | null>(null);
168
-
169
- const {
170
- items,
171
- isLoading,
172
- pagination,
173
- handleTableChange,
174
- reload,
175
- handleInputSearch,
176
- } = useUserList();
177
-
178
- const handleEdit = (record: User) => {
179
- setSelectedUser(record);
180
- setModalOpen(true);
181
- };
220
+ import { useUserList } from '../hooks/use-user-list';
221
+ import { useDeleteUser } from '../hooks/use-user-mutations';
222
+ import { UserTable } from '../components/user-table';
223
+ import { UserFormDialog } from '../components/user-form-dialog';
224
+ import type { User } from '../types/user.model';
225
+
226
+ export default function UserListPage() {
227
+ const [params, setParams] = useState({ page: 1, pageSize: 20 });
228
+ const [editing, setEditing] = useState<User | null>(null);
229
+ const [formOpen, setFormOpen] = useState(false);
230
+
231
+ const { data, isLoading } = useUserList(params);
232
+ const deleteUser = useDeleteUser();
182
233
 
183
234
  return (
184
- <div>
185
- <Button onClick={() => { setSelectedUser(null); setModalOpen(true); }}>
186
- Add User
187
- </Button>
235
+ <div className="container mx-auto p-6">
236
+ <div className="flex justify-between mb-6">
237
+ <h1 className="text-2xl font-bold">Users</h1>
238
+ <Button onClick={() => { setEditing(null); setFormOpen(true); }}>
239
+ Add User
240
+ </Button>
241
+ </div>
188
242
 
189
243
  <UserTable
190
- dataSource={items}
191
- loading={isLoading}
192
- pagination={pagination}
193
- onChange={handleTableChange}
194
- onEdit={handleEdit}
195
- onReload={reload}
244
+ data={data?.data ?? []}
245
+ meta={data?.meta}
246
+ isLoading={isLoading}
247
+ onEdit={(user) => { setEditing(user); setFormOpen(true); }}
248
+ onDelete={(user) => deleteUser.mutate(user.id)}
249
+ onPageChange={(page) => setParams((p) => ({ ...p, page }))}
196
250
  />
197
251
 
198
- <UserModal
199
- open={modalOpen}
200
- onOpenChange={setModalOpen}
201
- user={selectedUser}
202
- onSuccess={reload}
252
+ <UserFormDialog
253
+ open={formOpen}
254
+ onOpenChange={setFormOpen}
255
+ user={editing}
203
256
  />
204
257
  </div>
205
258
  );
206
- };
259
+ }
207
260
  ```
208
261
 
209
- ### core/HttpClient.ts
210
- ```tsx
262
+ ### core/http-client.ts
263
+ ```ts
211
264
  const BASE_URL = import.meta.env.VITE_API_URL;
212
265
 
213
- const Client = {
214
- get: async <T>(url: string): Promise<T> => {
215
- const res = await fetch(`${BASE_URL}${url}`, {
216
- headers: { 'Authorization': `Bearer ${getToken()}` },
217
- });
218
- if (!res.ok) throw new Error(res.statusText);
219
- return res.json();
220
- },
221
-
222
- post: async <T>(url: string, options?: RequestInit): Promise<T> => {
223
- const res = await fetch(`${BASE_URL}${url}`, {
224
- method: 'POST',
225
- headers: {
226
- 'Content-Type': 'application/json',
227
- 'Authorization': `Bearer ${getToken()}`,
228
- },
229
- ...options,
230
- });
231
- if (!res.ok) throw new Error(res.statusText);
232
- return res.json();
233
- },
234
-
235
- delete: async (url: string): Promise<void> => {
236
- await fetch(`${BASE_URL}${url}`, {
237
- method: 'DELETE',
238
- headers: { 'Authorization': `Bearer ${getToken()}` },
266
+ const buildUrl = (path: string, params?: Record<string, unknown>) => {
267
+ const url = new URL(`${BASE_URL}${path}`);
268
+ if (params) {
269
+ Object.entries(params).forEach(([k, v]) => {
270
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
239
271
  });
240
- },
272
+ }
273
+ return url.toString();
241
274
  };
242
275
 
243
- export default Client;
276
+ const request = async <T>(path: string, init: RequestInit & { params?: Record<string, unknown> } = {}): Promise<T> => {
277
+ const { params, ...rest } = init;
278
+ const token = getToken();
279
+ const res = await fetch(buildUrl(path, params), {
280
+ ...rest,
281
+ headers: {
282
+ 'Content-Type': 'application/json',
283
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
284
+ ...rest.headers,
285
+ },
286
+ });
287
+
288
+ if (!res.ok) {
289
+ const body = await res.json().catch(() => ({}));
290
+ throw new HttpError(res.status, body?.message ?? res.statusText, body);
291
+ }
292
+
293
+ return res.status === 204 ? (undefined as T) : res.json();
294
+ };
295
+
296
+ export const httpClient = {
297
+ get: <T>(path: string, opts?: { params?: Record<string, unknown> }) => request<T>(path, { method: 'GET', ...opts }),
298
+ post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
299
+ patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
300
+ put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
301
+ delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
302
+ };
244
303
  ```
245
304
 
305
+ ## State Management
306
+
307
+ Pick the right tool per scope:
308
+
309
+ | Scope | Tool |
310
+ |-------|------|
311
+ | Server state (API data) | TanStack Query / SWR |
312
+ | Component-local state | `useState` / `useReducer` |
313
+ | Cross-component UI state | Context API |
314
+ | Global client state (cart, filters) | Zustand |
315
+ | Form state | React Hook Form + Zod |
316
+
317
+ Do NOT dump everything into Redux/Zustand — server state belongs in a query cache.
318
+
246
319
  ## Conventions
247
320
 
248
321
  | Item | Convention | Example |
249
322
  |------|------------|---------|
250
- | Module folder | kebab-case | `spam-slug/` |
251
- | Model file | kebab-case.model.ts | `spam-slug.model.ts` |
252
- | Service file | kebab-case.service.ts | `spam-slug.service.ts` |
253
- | ViewModel | use-kebab-case.ts | `use-spam-slug-list.ts` |
254
- | Component | PascalCase.tsx | `SpamSlugTable.tsx` |
255
- | Page | PascalCasePage.tsx | `SpamSlugListPage.tsx` |
256
-
257
- ## ViewModel Factory Returns
258
-
259
- ```tsx
260
- listViewModelFactory<T>() returns {
261
- items: T[],
262
- isLoading: boolean,
263
- pagination: { current, pageSize, total },
264
- handleTableChange: (pagination, filters, sorter) => void,
265
- handleInputSearch: (field, value) => void,
266
- reload: () => void,
267
- }
268
- ```
323
+ | Module folder | kebab-case | `user-profile/` |
324
+ | Type file | kebab-case.model.ts | `user.model.ts` |
325
+ | Schema file | kebab-case.schema.ts | `user.schema.ts` |
326
+ | Service file | kebab-case.service.ts | `user.service.ts` |
327
+ | Hook file | use-kebab-case.ts | `use-user-list.ts` |
328
+ | Component file | kebab-case.tsx | `user-table.tsx` |
329
+ | Component export | PascalCase named export | `export const UserTable` |
330
+ | Page file | kebab-case-page.tsx | `user-list-page.tsx` |
331
+ | Page export | default export | `export default UserListPage` |
332
+
333
+ ## Rules
334
+
335
+ - Components do NOT call services directly — always go through a hook
336
+ - Services are pure: no React, no state, just `fetch` + types
337
+ - Query keys live next to hooks and are exported for invalidation
338
+ - Validate external input at the boundary with Zod — trust types inside the app
339
+ - One module = one feature domain; shared code goes to `core/` or `components/ui/`