moicle 1.4.0 → 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.
@@ -10,6 +10,7 @@ Frontend application built with:
10
10
  - **shadcn/ui** - UI component library
11
11
  - **React Router 7** - Client-side routing
12
12
  - **TanStack Query** - Server state management
13
+ - **React Hook Form + Zod** - Forms & validation
13
14
 
14
15
  ## Quick Start
15
16
 
@@ -42,12 +43,13 @@ pnpm lint
42
43
  │ │ ├── layouts/ # Layout components
43
44
  │ │ │ ├── root-layout.tsx
44
45
  │ │ │ └── sidebar-layout.tsx
45
- │ │ ├── modules/ # Feature modules (MVVM)
46
+ │ │ ├── modules/ # Feature modules
46
47
  │ │ │ └── {module}/
47
- │ │ │ ├── components/
48
- │ │ │ ├── models/
49
- │ │ │ ├── viewmodels/
50
- │ │ │ └── pages/
48
+ │ │ │ ├── types/ # Models, DTOs, Zod schemas
49
+ │ │ │ ├── services/ # Pure API functions
50
+ │ │ │ ├── hooks/ # Query/mutation hooks
51
+ │ │ │ ├── components/ # Feature components
52
+ │ │ │ └── pages/ # Page components
51
53
  │ │ ├── shared/ # Shared utilities
52
54
  │ │ │ ├── components/
53
55
  │ │ │ ├── contexts/
@@ -55,7 +57,7 @@ pnpm lint
55
57
  │ │ └── router.tsx # Router setup
56
58
  │ ├── components/ui/ # shadcn/ui components
57
59
  │ ├── lib/
58
- │ │ ├── api-client.ts # API client
60
+ │ │ ├── http-client.ts # HTTP client
59
61
  │ │ └── utils.ts # Utility functions
60
62
  │ ├── main.tsx # App entry
61
63
  │ └── index.css # Global styles
@@ -72,63 +74,130 @@ pnpm lint
72
74
  ### File Naming
73
75
  - Use `kebab-case.tsx` for components
74
76
  - Use `use-*.ts` for hooks
75
- - Use `*.model.ts` for models
77
+ - Use `*.model.ts` for types
76
78
  - Use `*.schema.ts` for Zod schemas
79
+ - Use `*.service.ts` for API service functions
77
80
 
78
- ### MVVM Pattern
81
+ ### Module Layering
82
+
83
+ ```
84
+ pages / components → hooks → services → types
85
+ ```
86
+
87
+ - **Components** consume hooks only — never call services directly
88
+ - **Hooks** orchestrate queries/mutations and local state
89
+ - **Services** are pure: input → API → typed output
90
+ - **Types** are shared contracts
91
+
92
+ ### Types (models + schemas)
79
93
 
80
- **Model** - Data types and API functions:
81
94
  ```typescript
82
- // models/entity.model.ts
95
+ // modules/entity/types/entity.model.ts
83
96
  export interface Entity {
84
97
  id: string;
85
98
  name: string;
86
99
  status: 'active' | 'inactive';
87
100
  }
88
101
 
89
- export async function getEntities(): Promise<Entity[]> {
90
- return api.get<Entity[]>('/entities');
102
+ export interface CreateEntityDTO {
103
+ name: string;
104
+ }
105
+
106
+ export interface Paginated<T> {
107
+ data: T[];
108
+ meta: { page: number; perPage: number; total: number };
91
109
  }
110
+ ```
111
+
112
+ ```typescript
113
+ // modules/entity/types/entity.schema.ts
114
+ import { z } from 'zod';
115
+
116
+ export const createEntitySchema = z.object({
117
+ name: z.string().min(1, 'Name is required'),
118
+ });
119
+
120
+ export type CreateEntityFormData = z.infer<typeof createEntitySchema>;
121
+ ```
92
122
 
93
- export async function createEntity(data: CreateEntityDTO): Promise<Entity> {
94
- return api.post<Entity>('/entities', data);
123
+ ### Services (pure API calls)
124
+
125
+ ```typescript
126
+ // modules/entity/services/entity.service.ts
127
+ import { httpClient } from '@/lib/http-client';
128
+ import type { Entity, CreateEntityDTO, Paginated } from '../types/entity.model';
129
+
130
+ export const entityService = {
131
+ list: (params: { page?: number; search?: string }) =>
132
+ httpClient.get<Paginated<Entity>>('/entities', { params }),
133
+ detail: (id: string) => httpClient.get<Entity>(`/entities/${id}`),
134
+ create: (data: CreateEntityDTO) => httpClient.post<Entity>('/entities', data),
135
+ update: (id: string, data: Partial<CreateEntityDTO>) =>
136
+ httpClient.patch<Entity>(`/entities/${id}`, data),
137
+ remove: (id: string) => httpClient.delete<void>(`/entities/${id}`),
138
+ };
139
+ ```
140
+
141
+ ### Hooks (queries + mutations)
142
+
143
+ ```typescript
144
+ // modules/entity/hooks/use-entity-list.ts
145
+ import { useQuery } from '@tanstack/react-query';
146
+ import { entityService } from '../services/entity.service';
147
+
148
+ export const entityKeys = {
149
+ all: ['entities'] as const,
150
+ list: (params: unknown) => [...entityKeys.all, 'list', params] as const,
151
+ detail: (id: string) => [...entityKeys.all, 'detail', id] as const,
152
+ };
153
+
154
+ export function useEntityList(params: { page?: number; search?: string }) {
155
+ return useQuery({
156
+ queryKey: entityKeys.list(params),
157
+ queryFn: () => entityService.list(params),
158
+ placeholderData: (prev) => prev,
159
+ });
95
160
  }
96
161
  ```
97
162
 
98
- **ViewModel** - Business logic and state:
99
163
  ```typescript
100
- // viewmodels/use-entities.ts
101
- export function useEntitiesViewModel() {
102
- const [data, setData] = useState<Entity[]>([]);
103
- const [isLoading, setIsLoading] = useState(false);
104
- const [error, setError] = useState<string | null>(null);
105
-
106
- const loadData = useCallback(async () => {
107
- setIsLoading(true);
108
- try {
109
- const entities = await getEntities();
110
- setData(entities);
111
- } catch (err) {
112
- setError('Failed to load data');
113
- } finally {
114
- setIsLoading(false);
115
- }
116
- }, []);
117
-
118
- return { data, isLoading, error, loadData };
164
+ // modules/entity/hooks/use-entity-mutations.ts
165
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
166
+ import { entityService } from '../services/entity.service';
167
+ import { entityKeys } from './use-entity-list';
168
+ import type { CreateEntityDTO } from '../types/entity.model';
169
+
170
+ export function useCreateEntity() {
171
+ const qc = useQueryClient();
172
+ return useMutation({
173
+ mutationFn: (data: CreateEntityDTO) => entityService.create(data),
174
+ onSuccess: () => qc.invalidateQueries({ queryKey: entityKeys.all }),
175
+ });
176
+ }
177
+
178
+ export function useDeleteEntity() {
179
+ const qc = useQueryClient();
180
+ return useMutation({
181
+ mutationFn: (id: string) => entityService.remove(id),
182
+ onSuccess: () => qc.invalidateQueries({ queryKey: entityKeys.all }),
183
+ });
119
184
  }
120
185
  ```
121
186
 
122
- **View** - Pure UI components:
187
+ ### Components (pure UI)
188
+
123
189
  ```typescript
124
- // components/entity-table.tsx
190
+ // modules/entity/components/entity-table.tsx
125
191
  interface EntityTableProps {
126
192
  data: Entity[];
193
+ isLoading?: boolean;
127
194
  onEdit: (entity: Entity) => void;
128
195
  onDelete: (entity: Entity) => void;
129
196
  }
130
197
 
131
- export function EntityTable({ data, onEdit, onDelete }: EntityTableProps) {
198
+ export function EntityTable({ data, isLoading, onEdit, onDelete }: EntityTableProps) {
199
+ if (isLoading) return <TableSkeleton rows={5} />;
200
+
132
201
  return (
133
202
  <Table>
134
203
  {data.map((entity) => (
@@ -136,6 +205,7 @@ export function EntityTable({ data, onEdit, onDelete }: EntityTableProps) {
136
205
  <TableCell>{entity.name}</TableCell>
137
206
  <TableCell>
138
207
  <Button onClick={() => onEdit(entity)}>Edit</Button>
208
+ <Button variant="destructive" onClick={() => onDelete(entity)}>Delete</Button>
139
209
  </TableCell>
140
210
  </TableRow>
141
211
  ))}
@@ -144,125 +214,139 @@ export function EntityTable({ data, onEdit, onDelete }: EntityTableProps) {
144
214
  }
145
215
  ```
146
216
 
147
- ### API Client
217
+ ### Page (composition)
148
218
 
149
219
  ```typescript
150
- // lib/api-client.ts
151
- import { getAuth } from '@/app/shared/contexts/auth-context';
152
-
153
- const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
154
-
155
- export const api = {
156
- async get<T>(endpoint: string, options?: { requireAuth?: boolean }): Promise<T> {
157
- const headers: HeadersInit = { 'Content-Type': 'application/json' };
158
- if (options?.requireAuth !== false) {
159
- const token = await getAuth().currentUser?.getIdToken();
160
- headers['Authorization'] = `Bearer ${token}`;
161
- }
162
- const response = await fetch(`${API_BASE_URL}${endpoint}`, { headers });
163
- return response.json();
164
- },
165
-
166
- async post<T>(endpoint: string, data: unknown): Promise<T> {
167
- // Similar to get with method: 'POST' and body
168
- },
169
- };
170
- ```
171
-
172
- ### Page Component Pattern
173
-
174
- ```typescript
175
- // pages/entities-page.tsx
176
- export default function EntitiesPage() {
177
- const vm = useEntitiesPageViewModel();
178
-
179
- useEffect(() => {
180
- vm.loadData();
181
- }, [vm.loadData]);
220
+ // modules/entity/pages/entity-list-page.tsx
221
+ import { useState } from 'react';
222
+ import { useEntityList } from '../hooks/use-entity-list';
223
+ import { useDeleteEntity } from '../hooks/use-entity-mutations';
224
+ import { EntityTable } from '../components/entity-table';
225
+ import { EntityFormDialog } from '../components/entity-form-dialog';
226
+ import type { Entity } from '../types/entity.model';
227
+
228
+ export default function EntityListPage() {
229
+ const [params, setParams] = useState({ page: 1 });
230
+ const [editing, setEditing] = useState<Entity | null>(null);
231
+ const [formOpen, setFormOpen] = useState(false);
232
+
233
+ const { data, isLoading } = useEntityList(params);
234
+ const deleteEntity = useDeleteEntity();
182
235
 
183
236
  return (
184
237
  <div className="container mx-auto p-6">
185
238
  <div className="flex justify-between items-center mb-6">
186
239
  <h1 className="text-2xl font-bold">Entities</h1>
187
- <Button onClick={() => vm.setIsFormOpen(true)}>Add New</Button>
240
+ <Button onClick={() => { setEditing(null); setFormOpen(true); }}>Add New</Button>
188
241
  </div>
189
242
 
190
243
  <EntityTable
191
- data={vm.data}
192
- isLoading={vm.isLoading}
193
- onEdit={vm.handleEdit}
194
- onDelete={vm.handleDelete}
244
+ data={data?.data ?? []}
245
+ isLoading={isLoading}
246
+ onEdit={(e) => { setEditing(e); setFormOpen(true); }}
247
+ onDelete={(e) => deleteEntity.mutate(e.id)}
195
248
  />
196
249
 
197
- <EntityFormDialog
198
- open={vm.isFormOpen}
199
- onOpenChange={vm.setIsFormOpen}
200
- entity={vm.editingEntity}
201
- onSubmit={vm.handleSubmit}
202
- />
250
+ <EntityFormDialog open={formOpen} onOpenChange={setFormOpen} entity={editing} />
203
251
  </div>
204
252
  );
205
253
  }
206
254
  ```
207
255
 
208
- ## Adding New Module
256
+ ### HTTP Client
209
257
 
210
- 1. Create module directory structure:
211
- ```bash
212
- mkdir -p src/app/modules/{module}/{models,viewmodels,components,pages}
213
- ```
258
+ ```typescript
259
+ // lib/http-client.ts
260
+ const BASE_URL = import.meta.env.VITE_API_BASE_URL;
261
+
262
+ const buildUrl = (path: string, params?: Record<string, unknown>) => {
263
+ const url = new URL(`${BASE_URL}${path}`);
264
+ if (params) {
265
+ Object.entries(params).forEach(([k, v]) => {
266
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
267
+ });
268
+ }
269
+ return url.toString();
270
+ };
214
271
 
215
- 2. Create model (`models/entity.model.ts`)
216
- 3. Create viewmodel (`viewmodels/use-entity.ts`)
217
- 4. Create components (`components/*.tsx`)
218
- 5. Create page (`pages/entity-page.tsx`)
219
- 6. Add route in `src/app/router.tsx`
220
- 7. Add menu item in `src/app/config/menu.ts`
272
+ const request = async <T>(path: string, init: RequestInit & { params?: Record<string, unknown> } = {}): Promise<T> => {
273
+ const { params, ...rest } = init;
274
+ const token = await getAuthToken();
275
+ const res = await fetch(buildUrl(path, params), {
276
+ ...rest,
277
+ headers: {
278
+ 'Content-Type': 'application/json',
279
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
280
+ ...rest.headers,
281
+ },
282
+ });
221
283
 
222
- ## Component Guidelines
284
+ if (!res.ok) {
285
+ const body = await res.json().catch(() => ({}));
286
+ throw new HttpError(res.status, body?.message ?? res.statusText, body);
287
+ }
223
288
 
224
- ### Form with Zod Validation
289
+ return res.status === 204 ? (undefined as T) : res.json();
290
+ };
225
291
 
226
- ```typescript
227
- // models/entity.schema.ts
228
- import { z } from 'zod';
292
+ export const httpClient = {
293
+ get: <T>(path: string, opts?: { params?: Record<string, unknown> }) => request<T>(path, { method: 'GET', ...opts }),
294
+ post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
295
+ patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
296
+ put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
297
+ delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
298
+ };
299
+ ```
229
300
 
230
- export const entitySchema = z.object({
231
- name: z.string().min(1, 'Name is required'),
232
- email: z.string().email('Invalid email'),
233
- });
301
+ ## Adding a New Module
234
302
 
235
- export type EntityFormData = z.infer<typeof entitySchema>;
303
+ 1. Create module directory structure:
304
+ ```bash
305
+ mkdir -p src/app/modules/{module}/{types,services,hooks,components,pages}
236
306
  ```
237
307
 
308
+ 2. Create types (`types/{module}.model.ts`, `types/{module}.schema.ts`)
309
+ 3. Create service (`services/{module}.service.ts`)
310
+ 4. Create hooks (`hooks/use-{module}-list.ts`, `hooks/use-{module}-mutations.ts`)
311
+ 5. Create components (`components/*.tsx`)
312
+ 6. Create page (`pages/{module}-list-page.tsx`)
313
+ 7. Add route in `src/app/router.tsx`
314
+ 8. Add menu item in `src/app/config/menu.ts`
315
+
316
+ ## Form with Zod + React Hook Form
317
+
238
318
  ```typescript
239
- // components/entity-form.tsx
319
+ // modules/entity/components/entity-form.tsx
240
320
  import { useForm } from 'react-hook-form';
241
321
  import { zodResolver } from '@hookform/resolvers/zod';
322
+ import { createEntitySchema, type CreateEntityFormData } from '../types/entity.schema';
323
+ import { useCreateEntity } from '../hooks/use-entity-mutations';
242
324
 
243
- export function EntityForm({ onSubmit }: Props) {
244
- const form = useForm<EntityFormData>({
245
- resolver: zodResolver(entitySchema),
325
+ export function EntityForm({ onSuccess }: { onSuccess: () => void }) {
326
+ const form = useForm<CreateEntityFormData>({
327
+ resolver: zodResolver(createEntitySchema),
246
328
  });
329
+ const createEntity = useCreateEntity();
247
330
 
248
331
  return (
249
332
  <Form {...form}>
250
- <form onSubmit={form.handleSubmit(onSubmit)}>
333
+ <form onSubmit={form.handleSubmit((data) => createEntity.mutate(data, { onSuccess }))}>
251
334
  <FormField name="name" control={form.control} render={...} />
335
+ <Button type="submit" disabled={createEntity.isPending}>Save</Button>
252
336
  </form>
253
337
  </Form>
254
338
  );
255
339
  }
256
340
  ```
257
341
 
258
- ### Loading States
342
+ ## Loading States
259
343
 
260
344
  ```typescript
261
- // Use skeleton for initial load
345
+ // Initial load skeleton
262
346
  {isLoading && <TableSkeleton rows={5} />}
263
347
 
264
- // Use overlay for data refresh
265
- {isRefreshing && (
348
+ // Background refresh overlay
349
+ {isFetching && !isLoading && (
266
350
  <div className="absolute inset-0 bg-background/50 flex items-center justify-center">
267
351
  <Spinner />
268
352
  </div>
@@ -277,28 +361,20 @@ VITE_API_BASE_URL=http://localhost:8080
277
361
  VITE_APP_NAME={project_name}
278
362
  ```
279
363
 
280
- ### Tailwind Config
281
- ```typescript
282
- // tailwind.config.ts
283
- export default {
284
- content: ['./src/**/*.{ts,tsx}'],
285
- theme: {
286
- extend: {
287
- // Custom theme extensions
288
- },
289
- },
290
- plugins: [],
291
- };
292
- ```
293
-
294
364
  ### Path Aliases (tsconfig.json)
295
365
  ```json
296
366
  {
297
367
  "compilerOptions": {
298
368
  "baseUrl": ".",
299
- "paths": {
300
- "@/*": ["./src/*"]
301
- }
369
+ "paths": { "@/*": ["./src/*"] }
302
370
  }
303
371
  }
304
372
  ```
373
+
374
+ ## Rules
375
+
376
+ - Components NEVER call services directly — always via a hook
377
+ - Services are pure (no React imports)
378
+ - Query keys exported from the hook file, used for invalidation
379
+ - Validate external input with Zod at the boundary
380
+ - Server state lives in TanStack Query cache, not Zustand/Redux
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moicle",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Reusable AI agents, commands, skills, and architecture references for Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",