@atlashub/smartstack-cli 3.23.0 → 3.25.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.
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-entry.mjs +96 -24
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/mcp-scaffolding/component.tsx.hbs +21 -1
- package/templates/skills/apex/references/smartstack-api.md +174 -5
- package/templates/skills/apex/references/smartstack-frontend.md +1101 -0
- package/templates/skills/apex/references/smartstack-layers.md +81 -5
- package/templates/skills/apex/steps/step-01-analyze.md +27 -3
- package/templates/skills/apex/steps/step-02-plan.md +5 -1
- package/templates/skills/apex/steps/step-03-execute.md +47 -5
- package/templates/skills/apex/steps/step-04-validate.md +300 -0
- package/templates/skills/apex/steps/step-05-examine.md +7 -0
- package/templates/skills/apex/steps/step-07-tests.md +19 -0
- package/templates/skills/business-analyse/_shared.md +6 -6
- package/templates/skills/business-analyse/patterns/suggestion-catalog.md +1 -1
- package/templates/skills/business-analyse/questionnaire/07-ui.md +3 -3
- package/templates/skills/business-analyse/references/cadrage-pre-analysis.md +1 -1
- package/templates/skills/business-analyse/references/entity-architecture-decision.md +3 -3
- package/templates/skills/business-analyse/references/handoff-file-templates.md +13 -5
- package/templates/skills/business-analyse/references/spec-auto-inference.md +14 -14
- package/templates/skills/business-analyse/steps/step-01-cadrage.md +2 -2
- package/templates/skills/business-analyse/steps/step-02-decomposition.md +1 -1
- package/templates/skills/business-analyse/steps/step-03a1-setup.md +2 -2
- package/templates/skills/business-analyse/steps/step-03b-ui.md +2 -1
- package/templates/skills/business-analyse/steps/step-05a-handoff.md +15 -4
- package/templates/skills/business-analyse/templates/tpl-frd.md +2 -2
- package/templates/skills/business-analyse/templates-frd.md +2 -2
- package/templates/skills/efcore/steps/migration/step-02-create.md +14 -1
- package/templates/skills/ralph-loop/references/category-rules.md +71 -9
- package/templates/skills/ralph-loop/references/compact-loop.md +3 -3
- package/templates/skills/ralph-loop/references/core-seed-data.md +10 -0
- package/templates/skills/ralph-loop/steps/step-02-execute.md +190 -1
- package/templates/skills/validate-feature/steps/step-01-compile.md +4 -1
- package/templates/skills/validate-feature/steps/step-05-db-validation.md +86 -1
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
# SmartStack Frontend Patterns — Mandatory Reference
|
|
2
|
+
|
|
3
|
+
> **Loaded by:** step-03 (execution) and step-04 (validation)
|
|
4
|
+
> **Purpose:** Defines mandatory frontend patterns extracted from SmartStack.app.
|
|
5
|
+
> **Enforcement:** POST-CHECKs in step-04 verify compliance.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Lazy Loading (React.lazy + Suspense)
|
|
10
|
+
|
|
11
|
+
> **ALL page components MUST be lazy-loaded.** Only critical entry pages (HomePage, LoginPage) may use static imports.
|
|
12
|
+
|
|
13
|
+
### Import Pattern
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
// Named exports — use .then() to wrap
|
|
17
|
+
const EmployeesPage = lazy(() =>
|
|
18
|
+
import('@/pages/Business/HumanResources/Employees/EmployeesPage')
|
|
19
|
+
.then(m => ({ default: m.EmployeesPage }))
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Default exports — direct lazy
|
|
23
|
+
const DashboardPage = lazy(() => import('@/pages/Platform/Admin/DashboardPage'));
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Suspense Wrapper
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { Suspense } from 'react';
|
|
30
|
+
import { PageLoader } from '@/components/ui/PageLoader';
|
|
31
|
+
|
|
32
|
+
// Route element wrapping
|
|
33
|
+
element: (
|
|
34
|
+
<Suspense fallback={<PageLoader />}>
|
|
35
|
+
<PermissionGuard permissions={ROUTES['business.hr.employees'].permissions}>
|
|
36
|
+
<EmployeesPage />
|
|
37
|
+
</PermissionGuard>
|
|
38
|
+
</Suspense>
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Rules
|
|
43
|
+
|
|
44
|
+
- **NEVER** static-import page components in route files
|
|
45
|
+
- **ALWAYS** use `<Suspense fallback={<PageLoader />}>` around lazy components
|
|
46
|
+
- **ALWAYS** use the `.then(m => ({ default: m.ComponentName }))` pattern for named exports
|
|
47
|
+
- Layout components (AdminLayout, BusinessLayout, UserLayout) are ALSO lazy-loaded
|
|
48
|
+
|
|
49
|
+
**FORBIDDEN:**
|
|
50
|
+
```tsx
|
|
51
|
+
// WRONG: static import in route file
|
|
52
|
+
import { EmployeesPage } from '@/pages/Business/HumanResources/Employees/EmployeesPage';
|
|
53
|
+
|
|
54
|
+
// WRONG: no Suspense wrapper
|
|
55
|
+
element: <EmployeesPage />
|
|
56
|
+
|
|
57
|
+
// WRONG: no fallback
|
|
58
|
+
<Suspense><EmployeesPage /></Suspense>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Client App.tsx — Lazy Imports Mandatory
|
|
62
|
+
|
|
63
|
+
> **CRITICAL:** In the client `App.tsx` (where `contextRoutes` are defined), ALL page imports MUST use `React.lazy()`.
|
|
64
|
+
|
|
65
|
+
**CORRECT — Lazy imports in client App.tsx:**
|
|
66
|
+
```tsx
|
|
67
|
+
const ClientsListPage = lazy(() =>
|
|
68
|
+
import('@/pages/Business/HumanResources/Clients/ClientsListPage')
|
|
69
|
+
.then(m => ({ default: m.ClientsListPage }))
|
|
70
|
+
);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**FORBIDDEN — Static imports in client App.tsx:**
|
|
74
|
+
```tsx
|
|
75
|
+
// WRONG: Static import kills code splitting
|
|
76
|
+
import { ClientsListPage } from '@/pages/Business/HumanResources/Clients/ClientsListPage';
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
> **Note:** The `smartstackRoutes.tsx` from the npm package may use static imports internally — this is acceptable for the package. But client `App.tsx` code MUST always use lazy imports for business pages.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 2. I18n / Translations (react-i18next)
|
|
84
|
+
|
|
85
|
+
> **ALL user-facing text MUST use translations.** 4 languages required: fr, en, it, de.
|
|
86
|
+
|
|
87
|
+
### File Structure
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
src/i18n/
|
|
91
|
+
├── config.ts # i18n initialization
|
|
92
|
+
├── locales/
|
|
93
|
+
│ ├── fr/
|
|
94
|
+
│ │ ├── common.json # Shared keys (actions, errors, validation)
|
|
95
|
+
│ │ ├── navigation.json # Menu labels
|
|
96
|
+
│ │ └── {module}.json # Module-specific keys
|
|
97
|
+
│ ├── en/
|
|
98
|
+
│ │ └── {module}.json
|
|
99
|
+
│ ├── it/
|
|
100
|
+
│ │ └── {module}.json
|
|
101
|
+
│ └── de/
|
|
102
|
+
│ └── {module}.json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Module JSON Template
|
|
106
|
+
|
|
107
|
+
Each new module MUST generate a translation file with this structure:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"title": "Module display name",
|
|
112
|
+
"description": "Module description",
|
|
113
|
+
"actions": {
|
|
114
|
+
"create": "Create {entity}",
|
|
115
|
+
"edit": "Edit {entity}",
|
|
116
|
+
"delete": "Delete {entity}",
|
|
117
|
+
"save": "Save",
|
|
118
|
+
"cancel": "Cancel",
|
|
119
|
+
"search": "Search...",
|
|
120
|
+
"export": "Export",
|
|
121
|
+
"refresh": "Refresh"
|
|
122
|
+
},
|
|
123
|
+
"labels": {
|
|
124
|
+
"name": "Name",
|
|
125
|
+
"code": "Code",
|
|
126
|
+
"description": "Description",
|
|
127
|
+
"status": "Status",
|
|
128
|
+
"createdAt": "Created at",
|
|
129
|
+
"updatedAt": "Updated at",
|
|
130
|
+
"createdBy": "Created by",
|
|
131
|
+
"isActive": "Active"
|
|
132
|
+
},
|
|
133
|
+
"columns": {
|
|
134
|
+
"name": "Name",
|
|
135
|
+
"code": "Code",
|
|
136
|
+
"status": "Status",
|
|
137
|
+
"actions": "Actions"
|
|
138
|
+
},
|
|
139
|
+
"form": {
|
|
140
|
+
"name": "Name",
|
|
141
|
+
"namePlaceholder": "Enter name...",
|
|
142
|
+
"code": "Code",
|
|
143
|
+
"codePlaceholder": "Enter code...",
|
|
144
|
+
"description": "Description",
|
|
145
|
+
"descriptionPlaceholder": "Enter description..."
|
|
146
|
+
},
|
|
147
|
+
"errors": {
|
|
148
|
+
"loadFailed": "Failed to load data",
|
|
149
|
+
"saveFailed": "Failed to save",
|
|
150
|
+
"deleteFailed": "Failed to delete",
|
|
151
|
+
"notFound": "Not found",
|
|
152
|
+
"permissionDenied": "Permission denied"
|
|
153
|
+
},
|
|
154
|
+
"validation": {
|
|
155
|
+
"nameRequired": "Name is required",
|
|
156
|
+
"codeRequired": "Code is required",
|
|
157
|
+
"nameMaxLength": "Name must be less than {{max}} characters"
|
|
158
|
+
},
|
|
159
|
+
"messages": {
|
|
160
|
+
"created": "{entity} created successfully",
|
|
161
|
+
"updated": "{entity} updated successfully",
|
|
162
|
+
"deleted": "{entity} deleted successfully",
|
|
163
|
+
"confirmDelete": "Are you sure you want to delete this {entity}?"
|
|
164
|
+
},
|
|
165
|
+
"empty": {
|
|
166
|
+
"title": "No {entity} found",
|
|
167
|
+
"description": "Create your first {entity} to get started"
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Usage in Components
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
// Hook — specify namespace(s)
|
|
176
|
+
const { t } = useTranslation(['employees']);
|
|
177
|
+
|
|
178
|
+
// Simple key with MANDATORY fallback
|
|
179
|
+
t('employees:title', 'Employees')
|
|
180
|
+
|
|
181
|
+
// Key with interpolation
|
|
182
|
+
t('employees:messages.created', '{{entity}} created successfully', { entity: 'Employee' })
|
|
183
|
+
|
|
184
|
+
// Namespace prefix syntax
|
|
185
|
+
t('employees:actions.create', 'Create employee')
|
|
186
|
+
t('common:actions.save', 'Save')
|
|
187
|
+
t('common:errors.network', 'Network error')
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Rules
|
|
191
|
+
|
|
192
|
+
- **ALWAYS** provide a fallback value as 2nd argument to `t()`
|
|
193
|
+
- **ALWAYS** use namespace prefix: `t('namespace:key')`
|
|
194
|
+
- **ALWAYS** generate 4 language files (fr, en, it, de) with identical key structures
|
|
195
|
+
- **NEVER** hardcode user-facing strings in JSX
|
|
196
|
+
- **NEVER** use `t('key')` without namespace prefix
|
|
197
|
+
|
|
198
|
+
**FORBIDDEN:**
|
|
199
|
+
```tsx
|
|
200
|
+
// WRONG: no fallback
|
|
201
|
+
t('employees:title')
|
|
202
|
+
|
|
203
|
+
// WRONG: no namespace
|
|
204
|
+
t('title')
|
|
205
|
+
|
|
206
|
+
// WRONG: hardcoded text
|
|
207
|
+
<h1>Employees</h1>
|
|
208
|
+
|
|
209
|
+
// WRONG: only 2 languages generated
|
|
210
|
+
// Must have fr, en, it, de
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 3. Page Structure Pattern
|
|
216
|
+
|
|
217
|
+
> **ALL pages MUST follow this structure.** Extracted from SmartStack.app reference implementation.
|
|
218
|
+
|
|
219
|
+
### Standard List Page Template
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
223
|
+
import { useTranslation } from 'react-i18next';
|
|
224
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
225
|
+
import { Loader2 } from 'lucide-react';
|
|
226
|
+
|
|
227
|
+
// API hook (generated by scaffold_api_client)
|
|
228
|
+
import { useEntityList } from '@/hooks/useEntity';
|
|
229
|
+
|
|
230
|
+
export function EntityListPage() {
|
|
231
|
+
// 1. HOOKS — always at the top
|
|
232
|
+
const { t } = useTranslation(['{module}']);
|
|
233
|
+
const navigate = useNavigate();
|
|
234
|
+
|
|
235
|
+
// 2. STATE
|
|
236
|
+
const [loading, setLoading] = useState(true);
|
|
237
|
+
const [error, setError] = useState<string | null>(null);
|
|
238
|
+
const [data, setData] = useState<Entity[]>([]);
|
|
239
|
+
|
|
240
|
+
// 3. DATA LOADING (useCallback + useEffect)
|
|
241
|
+
const loadData = useCallback(async () => {
|
|
242
|
+
try {
|
|
243
|
+
setLoading(true);
|
|
244
|
+
setError(null);
|
|
245
|
+
const result = await entityApi.getAll();
|
|
246
|
+
setData(result.items);
|
|
247
|
+
} catch (err: any) {
|
|
248
|
+
setError(err.message || t('{module}:errors.loadFailed', 'Failed to load data'));
|
|
249
|
+
} finally {
|
|
250
|
+
setLoading(false);
|
|
251
|
+
}
|
|
252
|
+
}, [t]);
|
|
253
|
+
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
loadData();
|
|
256
|
+
}, [loadData]);
|
|
257
|
+
|
|
258
|
+
// 4. LOADING STATE
|
|
259
|
+
if (loading) {
|
|
260
|
+
return (
|
|
261
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
262
|
+
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 5. ERROR STATE
|
|
268
|
+
if (error) {
|
|
269
|
+
return (
|
|
270
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
271
|
+
<div className="text-center">
|
|
272
|
+
<p className="text-[var(--text-secondary)]">{error}</p>
|
|
273
|
+
<button
|
|
274
|
+
onClick={loadData}
|
|
275
|
+
className="mt-4 px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
|
|
276
|
+
>
|
|
277
|
+
{t('common:actions.retry', 'Retry')}
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 6. CONTENT — create button navigates to /create route
|
|
285
|
+
return (
|
|
286
|
+
<div className="space-y-6">
|
|
287
|
+
{/* Header */}
|
|
288
|
+
<div className="flex items-center justify-between">
|
|
289
|
+
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
|
290
|
+
{t('{module}:title', 'Module Title')}
|
|
291
|
+
</h1>
|
|
292
|
+
<button
|
|
293
|
+
onClick={() => navigate('create')}
|
|
294
|
+
className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
|
|
295
|
+
>
|
|
296
|
+
{t('{module}:actions.create', 'Create')}
|
|
297
|
+
</button>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Content: SmartTable with row click → detail, edit action → /:id/edit */}
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Detail Page Pattern
|
|
307
|
+
|
|
308
|
+
```tsx
|
|
309
|
+
export function EntityDetailPage() {
|
|
310
|
+
const { entityId } = useParams<{ entityId: string }>();
|
|
311
|
+
const { t } = useTranslation(['{module}']);
|
|
312
|
+
const navigate = useNavigate();
|
|
313
|
+
|
|
314
|
+
const [entity, setEntity] = useState<Entity | null>(null);
|
|
315
|
+
const [loading, setLoading] = useState(true);
|
|
316
|
+
const [activeTab, setActiveTab] = useState('info');
|
|
317
|
+
|
|
318
|
+
// Lazy tab loading — load data only when tab is first visited
|
|
319
|
+
const visitedTabsRef = useRef<Set<string>>(new Set(['info']));
|
|
320
|
+
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
if (!visitedTabsRef.current.has(activeTab)) {
|
|
323
|
+
visitedTabsRef.current.add(activeTab);
|
|
324
|
+
// Load tab-specific data here
|
|
325
|
+
}
|
|
326
|
+
}, [activeTab]);
|
|
327
|
+
|
|
328
|
+
// Edit button navigates to /:id/edit route (NEVER opens a modal)
|
|
329
|
+
const handleEdit = () => navigate(`/${basePath}/${entityId}/edit`);
|
|
330
|
+
|
|
331
|
+
// ... loading/error/content pattern
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## 3b. Form Pages Pattern (Create / Edit)
|
|
338
|
+
|
|
339
|
+
> **CRITICAL: ALL forms MUST be full pages with their own URL route.**
|
|
340
|
+
> **NEVER use modals, dialogs, drawers, or popups for create/edit forms.**
|
|
341
|
+
|
|
342
|
+
### Route Convention
|
|
343
|
+
|
|
344
|
+
| Action | Route pattern | Page component | File location |
|
|
345
|
+
|--------|--------------|----------------|---------------|
|
|
346
|
+
| Create | `/{module}/create` | `EntityCreatePage` | `src/pages/{Context}/{App}/{Module}/EntityCreatePage.tsx` |
|
|
347
|
+
| Edit | `/{module}/:id/edit` | `EntityEditPage` | `src/pages/{Context}/{App}/{Module}/EntityEditPage.tsx` |
|
|
348
|
+
|
|
349
|
+
### Create Page Template
|
|
350
|
+
|
|
351
|
+
```tsx
|
|
352
|
+
import { useState } from 'react';
|
|
353
|
+
import { useTranslation } from 'react-i18next';
|
|
354
|
+
import { useNavigate } from 'react-router-dom';
|
|
355
|
+
|
|
356
|
+
export function EntityCreatePage() {
|
|
357
|
+
const { t } = useTranslation(['{module}']);
|
|
358
|
+
const navigate = useNavigate();
|
|
359
|
+
const [submitting, setSubmitting] = useState(false);
|
|
360
|
+
|
|
361
|
+
const handleSubmit = async (data: CreateEntityDto) => {
|
|
362
|
+
try {
|
|
363
|
+
setSubmitting(true);
|
|
364
|
+
await entityApi.create(data);
|
|
365
|
+
navigate(-1); // Back to list
|
|
366
|
+
} catch (err: any) {
|
|
367
|
+
// Handle validation errors
|
|
368
|
+
} finally {
|
|
369
|
+
setSubmitting(false);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<div className="space-y-6">
|
|
375
|
+
{/* Back button */}
|
|
376
|
+
<button
|
|
377
|
+
onClick={() => navigate(-1)}
|
|
378
|
+
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
379
|
+
>
|
|
380
|
+
{t('common:actions.back', 'Back')}
|
|
381
|
+
</button>
|
|
382
|
+
|
|
383
|
+
{/* Page title */}
|
|
384
|
+
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
|
385
|
+
{t('{module}:actions.create', 'Create {Entity}')}
|
|
386
|
+
</h1>
|
|
387
|
+
|
|
388
|
+
{/* SmartForm — NEVER in a modal */}
|
|
389
|
+
<SmartForm
|
|
390
|
+
fields={formFields}
|
|
391
|
+
onSubmit={handleSubmit}
|
|
392
|
+
onCancel={() => navigate(-1)}
|
|
393
|
+
submitting={submitting}
|
|
394
|
+
/>
|
|
395
|
+
</div>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Edit Page Template
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
404
|
+
import { useTranslation } from 'react-i18next';
|
|
405
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
406
|
+
import { Loader2 } from 'lucide-react';
|
|
407
|
+
|
|
408
|
+
export function EntityEditPage() {
|
|
409
|
+
const { entityId } = useParams<{ entityId: string }>();
|
|
410
|
+
const { t } = useTranslation(['{module}']);
|
|
411
|
+
const navigate = useNavigate();
|
|
412
|
+
const [entity, setEntity] = useState<Entity | null>(null);
|
|
413
|
+
const [loading, setLoading] = useState(true);
|
|
414
|
+
const [submitting, setSubmitting] = useState(false);
|
|
415
|
+
|
|
416
|
+
const loadEntity = useCallback(async () => {
|
|
417
|
+
try {
|
|
418
|
+
setLoading(true);
|
|
419
|
+
const result = await entityApi.getById(entityId!);
|
|
420
|
+
setEntity(result);
|
|
421
|
+
} catch {
|
|
422
|
+
navigate(-1);
|
|
423
|
+
} finally {
|
|
424
|
+
setLoading(false);
|
|
425
|
+
}
|
|
426
|
+
}, [entityId, navigate]);
|
|
427
|
+
|
|
428
|
+
useEffect(() => { loadEntity(); }, [loadEntity]);
|
|
429
|
+
|
|
430
|
+
if (loading) {
|
|
431
|
+
return (
|
|
432
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
433
|
+
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const handleSubmit = async (data: UpdateEntityDto) => {
|
|
439
|
+
try {
|
|
440
|
+
setSubmitting(true);
|
|
441
|
+
await entityApi.update(entityId!, data);
|
|
442
|
+
navigate(-1); // Back to detail or list
|
|
443
|
+
} catch (err: any) {
|
|
444
|
+
// Handle validation errors
|
|
445
|
+
} finally {
|
|
446
|
+
setSubmitting(false);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
return (
|
|
451
|
+
<div className="space-y-6">
|
|
452
|
+
{/* Back button */}
|
|
453
|
+
<button
|
|
454
|
+
onClick={() => navigate(-1)}
|
|
455
|
+
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
456
|
+
>
|
|
457
|
+
{t('common:actions.back', 'Back')}
|
|
458
|
+
</button>
|
|
459
|
+
|
|
460
|
+
{/* Page title */}
|
|
461
|
+
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
|
462
|
+
{t('{module}:actions.edit', 'Edit {Entity}')}
|
|
463
|
+
</h1>
|
|
464
|
+
|
|
465
|
+
{/* SmartForm pre-filled — NEVER in a modal */}
|
|
466
|
+
<SmartForm
|
|
467
|
+
fields={formFields}
|
|
468
|
+
initialValues={entity}
|
|
469
|
+
onSubmit={handleSubmit}
|
|
470
|
+
onCancel={() => navigate(-1)}
|
|
471
|
+
submitting={submitting}
|
|
472
|
+
/>
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### Lazy Loading for Form Pages
|
|
479
|
+
|
|
480
|
+
```tsx
|
|
481
|
+
// In route files — form pages are also lazy-loaded
|
|
482
|
+
const EntityCreatePage = lazy(() =>
|
|
483
|
+
import('@/pages/Business/HumanResources/Employees/EntityCreatePage')
|
|
484
|
+
.then(m => ({ default: m.EntityCreatePage }))
|
|
485
|
+
);
|
|
486
|
+
const EntityEditPage = lazy(() =>
|
|
487
|
+
import('@/pages/Business/HumanResources/Employees/EntityEditPage')
|
|
488
|
+
.then(m => ({ default: m.EntityEditPage }))
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
// Route registration — form pages have their own routes
|
|
492
|
+
{
|
|
493
|
+
path: 'employees',
|
|
494
|
+
children: [
|
|
495
|
+
{ index: true, element: <Suspense fallback={<PageLoader />}><EmployeesPage /></Suspense> },
|
|
496
|
+
{ path: 'create', element: <Suspense fallback={<PageLoader />}><EntityCreatePage /></Suspense> },
|
|
497
|
+
{ path: ':id', element: <Suspense fallback={<PageLoader />}><EntityDetailPage /></Suspense> },
|
|
498
|
+
{ path: ':id/edit', element: <Suspense fallback={<PageLoader />}><EntityEditPage /></Suspense> },
|
|
499
|
+
]
|
|
500
|
+
}
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
### Rules
|
|
504
|
+
|
|
505
|
+
- **NEVER** use `<Modal>`, `<Dialog>`, `<Drawer>`, or `<Popup>` for create/edit forms
|
|
506
|
+
- **NEVER** use `useState(isOpen)` to toggle form visibility — forms are pages, not overlays
|
|
507
|
+
- **ALWAYS** create a dedicated `EntityCreatePage.tsx` and `EntityEditPage.tsx` page component
|
|
508
|
+
- **ALWAYS** register create/edit routes alongside list/detail routes
|
|
509
|
+
- **ALWAYS** use `navigate('create')` or `navigate(\`${id}/edit\`)` from list/detail pages
|
|
510
|
+
- **ALWAYS** include a back button that uses `navigate(-1)` to return to previous page
|
|
511
|
+
|
|
512
|
+
**FORBIDDEN:**
|
|
513
|
+
```tsx
|
|
514
|
+
// WRONG: modal for create form
|
|
515
|
+
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
516
|
+
<Modal open={showCreateModal}><CreateForm /></Modal>
|
|
517
|
+
|
|
518
|
+
// WRONG: dialog for edit form
|
|
519
|
+
<Dialog open={editDialogOpen}><EditForm entity={selected} /></Dialog>
|
|
520
|
+
|
|
521
|
+
// WRONG: drawer for form
|
|
522
|
+
<Drawer open={isDrawerOpen}><SmartForm /></Drawer>
|
|
523
|
+
|
|
524
|
+
// WRONG: inline form toggle
|
|
525
|
+
{isEditing ? <EditForm /> : <DetailView />}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
---
|
|
529
|
+
|
|
530
|
+
## 4. CSS Variables (Theme System)
|
|
531
|
+
|
|
532
|
+
> **NEVER use hardcoded Tailwind colors.** ALWAYS use CSS variables for theme support.
|
|
533
|
+
|
|
534
|
+
### Variable Reference
|
|
535
|
+
|
|
536
|
+
| Usage | CSS Variable | Example |
|
|
537
|
+
|-------|-------------|---------|
|
|
538
|
+
| Background | `var(--bg-primary)` | `bg-[var(--bg-primary)]` |
|
|
539
|
+
| Card background | `var(--bg-card)` | `bg-[var(--bg-card)]` |
|
|
540
|
+
| Text primary | `var(--text-primary)` | `text-[var(--text-primary)]` |
|
|
541
|
+
| Text secondary | `var(--text-secondary)` | `text-[var(--text-secondary)]` |
|
|
542
|
+
| Borders | `var(--border-color)` | `border-[var(--border-color)]` |
|
|
543
|
+
| Accent | `var(--color-accent-500)` | `text-[var(--color-accent-500)]` |
|
|
544
|
+
| Card radius | `var(--radius-card)` | `style={{ borderRadius: 'var(--radius-card)' }}` |
|
|
545
|
+
|
|
546
|
+
### Card Pattern
|
|
547
|
+
|
|
548
|
+
```tsx
|
|
549
|
+
<div
|
|
550
|
+
className="bg-[var(--bg-card)] border border-[var(--border-color)] p-6"
|
|
551
|
+
style={{ borderRadius: 'var(--radius-card)' }}
|
|
552
|
+
>
|
|
553
|
+
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Title</h2>
|
|
554
|
+
<p className="text-sm text-[var(--text-secondary)]">Description</p>
|
|
555
|
+
</div>
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
**FORBIDDEN:**
|
|
559
|
+
```tsx
|
|
560
|
+
// WRONG: hardcoded Tailwind colors
|
|
561
|
+
className="bg-white border-gray-200 text-gray-900"
|
|
562
|
+
|
|
563
|
+
// WRONG: hardcoded hex/rgb
|
|
564
|
+
style={{ backgroundColor: '#ffffff', color: '#1a1a1a' }}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
## 5. Component Rules
|
|
570
|
+
|
|
571
|
+
| Need | Component | Source |
|
|
572
|
+
|------|-----------|--------|
|
|
573
|
+
| Data table | `SmartTable` | `@/components/SmartTable` |
|
|
574
|
+
| Filters | `SmartFilter` | `@/components/SmartFilter` |
|
|
575
|
+
| Entity cards | `EntityCard` | `@/components/EntityCard` |
|
|
576
|
+
| Forms | `SmartForm` | `@/components/SmartForm` |
|
|
577
|
+
| FK field lookup | `EntityLookup` | `@/components/ui/EntityLookup` |
|
|
578
|
+
| Statistics | `StatCard` | `@/components/StatCard` |
|
|
579
|
+
| Loading spinner | `Loader2` | `lucide-react` |
|
|
580
|
+
| Page loader | `PageLoader` | `@/components/ui/PageLoader` |
|
|
581
|
+
|
|
582
|
+
### Rules
|
|
583
|
+
|
|
584
|
+
- **NEVER** use raw `<table>` — use SmartTable
|
|
585
|
+
- **NEVER** create custom spinners — use `Loader2` from lucide-react
|
|
586
|
+
- **NEVER** import axios directly — use `@/services/api/apiClient`
|
|
587
|
+
- **ALWAYS** use `PageLoader` as Suspense fallback
|
|
588
|
+
- **ALWAYS** use existing shared components before creating new ones
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## 6. Foreign Key Fields & Entity Lookup (CRITICAL)
|
|
593
|
+
|
|
594
|
+
> **NEVER render a foreign key (Guid) as a plain text input.** FK fields MUST use a searchable lookup component.
|
|
595
|
+
> A form asking the user to type a GUID manually is a UX failure. ALL FK fields must provide entity search & selection.
|
|
596
|
+
|
|
597
|
+
### Field Type Classification
|
|
598
|
+
|
|
599
|
+
When generating form fields, determine the field type from the entity property:
|
|
600
|
+
|
|
601
|
+
| Property type | Form field type | Component |
|
|
602
|
+
|---------------|----------------|-----------|
|
|
603
|
+
| `string` | Text input | `<input type="text" />` |
|
|
604
|
+
| `string?` | Text input (optional) | `<input type="text" />` |
|
|
605
|
+
| `Guid` (FK — e.g., `EmployeeId`, `DepartmentId`) | **Entity Lookup** | `<EntityLookup />` |
|
|
606
|
+
| `bool` | Toggle/Checkbox | `<input type="checkbox" />` |
|
|
607
|
+
| `int` / `decimal` | Number input | `<input type="number" />` |
|
|
608
|
+
| `DateTime` | Date picker | `<input type="date" />` |
|
|
609
|
+
| `enum` | Select dropdown | `<select>` |
|
|
610
|
+
|
|
611
|
+
**How to detect FK fields:** Any property named `{Entity}Id` of type `Guid` that has a corresponding navigation property is a foreign key. Examples: `EmployeeId`, `DepartmentId`, `CategoryId`, `ParentId`.
|
|
612
|
+
|
|
613
|
+
### EntityLookup Component Pattern
|
|
614
|
+
|
|
615
|
+
```tsx
|
|
616
|
+
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
|
617
|
+
import { useTranslation } from 'react-i18next';
|
|
618
|
+
import { Search, X, ChevronDown } from 'lucide-react';
|
|
619
|
+
import { apiClient } from '@/services/api/apiClient';
|
|
620
|
+
|
|
621
|
+
interface EntityLookupOption {
|
|
622
|
+
id: string;
|
|
623
|
+
label: string; // Display name (e.g., employee full name)
|
|
624
|
+
sublabel?: string; // Secondary info (e.g., department, code)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
interface EntityLookupProps {
|
|
628
|
+
/** API endpoint to search entities (e.g., '/api/business/human-resources/employees') */
|
|
629
|
+
apiEndpoint: string;
|
|
630
|
+
/** Currently selected entity ID */
|
|
631
|
+
value: string | null;
|
|
632
|
+
/** Callback when entity is selected */
|
|
633
|
+
onChange: (id: string | null) => void;
|
|
634
|
+
/** Field label */
|
|
635
|
+
label: string;
|
|
636
|
+
/** Placeholder text */
|
|
637
|
+
placeholder?: string;
|
|
638
|
+
/** Map API response item to display option */
|
|
639
|
+
mapOption: (item: any) => EntityLookupOption;
|
|
640
|
+
/** Whether the field is required */
|
|
641
|
+
required?: boolean;
|
|
642
|
+
/** Whether the field is disabled */
|
|
643
|
+
disabled?: boolean;
|
|
644
|
+
/** Error message to display */
|
|
645
|
+
error?: string;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function EntityLookup({
|
|
649
|
+
apiEndpoint,
|
|
650
|
+
value,
|
|
651
|
+
onChange,
|
|
652
|
+
label,
|
|
653
|
+
placeholder,
|
|
654
|
+
mapOption,
|
|
655
|
+
required = false,
|
|
656
|
+
disabled = false,
|
|
657
|
+
error,
|
|
658
|
+
}: EntityLookupProps) {
|
|
659
|
+
const { t } = useTranslation(['common']);
|
|
660
|
+
const [search, setSearch] = useState('');
|
|
661
|
+
const [options, setOptions] = useState<EntityLookupOption[]>([]);
|
|
662
|
+
const [selectedOption, setSelectedOption] = useState<EntityLookupOption | null>(null);
|
|
663
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
664
|
+
const [loading, setLoading] = useState(false);
|
|
665
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
666
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
667
|
+
|
|
668
|
+
// Load selected entity display on mount (when value is set but no label)
|
|
669
|
+
useEffect(() => {
|
|
670
|
+
if (value && !selectedOption) {
|
|
671
|
+
apiClient.get(`${apiEndpoint}/${value}`)
|
|
672
|
+
.then(res => setSelectedOption(mapOption(res.data)))
|
|
673
|
+
.catch(() => { /* Entity not found — clear */ });
|
|
674
|
+
}
|
|
675
|
+
}, [value, apiEndpoint, mapOption, selectedOption]);
|
|
676
|
+
|
|
677
|
+
// Debounced search — 300ms delay, minimum 2 characters
|
|
678
|
+
const handleSearch = useCallback((term: string) => {
|
|
679
|
+
setSearch(term);
|
|
680
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
681
|
+
|
|
682
|
+
if (term.length < 2) {
|
|
683
|
+
setOptions([]);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
debounceRef.current = setTimeout(async () => {
|
|
688
|
+
setLoading(true);
|
|
689
|
+
try {
|
|
690
|
+
const res = await apiClient.get(apiEndpoint, {
|
|
691
|
+
params: { search: term, pageSize: 20 },
|
|
692
|
+
});
|
|
693
|
+
setOptions((res.data.items || res.data).map(mapOption));
|
|
694
|
+
} catch {
|
|
695
|
+
setOptions([]);
|
|
696
|
+
} finally {
|
|
697
|
+
setLoading(false);
|
|
698
|
+
}
|
|
699
|
+
}, 300);
|
|
700
|
+
}, [apiEndpoint, mapOption]);
|
|
701
|
+
|
|
702
|
+
// Load initial options when dropdown opens (show first 20)
|
|
703
|
+
const handleOpen = useCallback(async () => {
|
|
704
|
+
if (disabled) return;
|
|
705
|
+
setIsOpen(true);
|
|
706
|
+
if (options.length === 0 && search.length < 2) {
|
|
707
|
+
setLoading(true);
|
|
708
|
+
try {
|
|
709
|
+
const res = await apiClient.get(apiEndpoint, {
|
|
710
|
+
params: { pageSize: 20 },
|
|
711
|
+
});
|
|
712
|
+
setOptions((res.data.items || res.data).map(mapOption));
|
|
713
|
+
} catch {
|
|
714
|
+
setOptions([]);
|
|
715
|
+
} finally {
|
|
716
|
+
setLoading(false);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}, [disabled, apiEndpoint, mapOption, options.length, search.length]);
|
|
720
|
+
|
|
721
|
+
// Select entity
|
|
722
|
+
const handleSelect = useCallback((option: EntityLookupOption) => {
|
|
723
|
+
setSelectedOption(option);
|
|
724
|
+
onChange(option.id);
|
|
725
|
+
setIsOpen(false);
|
|
726
|
+
setSearch('');
|
|
727
|
+
}, [onChange]);
|
|
728
|
+
|
|
729
|
+
// Clear selection
|
|
730
|
+
const handleClear = useCallback(() => {
|
|
731
|
+
setSelectedOption(null);
|
|
732
|
+
onChange(null);
|
|
733
|
+
setSearch('');
|
|
734
|
+
}, [onChange]);
|
|
735
|
+
|
|
736
|
+
// Close on outside click
|
|
737
|
+
useEffect(() => {
|
|
738
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
739
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
740
|
+
setIsOpen(false);
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
744
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
745
|
+
}, []);
|
|
746
|
+
|
|
747
|
+
return (
|
|
748
|
+
<div ref={containerRef} className="relative">
|
|
749
|
+
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
750
|
+
{label} {required && <span className="text-[var(--error-text)]">*</span>}
|
|
751
|
+
</label>
|
|
752
|
+
|
|
753
|
+
{/* Selected value display OR search input */}
|
|
754
|
+
{selectedOption && !isOpen ? (
|
|
755
|
+
<div className="flex items-center gap-2 px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)]">
|
|
756
|
+
<div className="flex-1">
|
|
757
|
+
<span className="text-[var(--text-primary)]">{selectedOption.label}</span>
|
|
758
|
+
{selectedOption.sublabel && (
|
|
759
|
+
<span className="ml-2 text-sm text-[var(--text-secondary)]">{selectedOption.sublabel}</span>
|
|
760
|
+
)}
|
|
761
|
+
</div>
|
|
762
|
+
{!disabled && (
|
|
763
|
+
<button type="button" onClick={handleClear} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
|
|
764
|
+
<X className="w-4 h-4" />
|
|
765
|
+
</button>
|
|
766
|
+
)}
|
|
767
|
+
<button type="button" onClick={handleOpen} className="text-[var(--text-secondary)]">
|
|
768
|
+
<ChevronDown className="w-4 h-4" />
|
|
769
|
+
</button>
|
|
770
|
+
</div>
|
|
771
|
+
) : (
|
|
772
|
+
<div className="relative">
|
|
773
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
|
|
774
|
+
<input
|
|
775
|
+
type="text"
|
|
776
|
+
value={search}
|
|
777
|
+
onChange={(e) => handleSearch(e.target.value)}
|
|
778
|
+
onFocus={handleOpen}
|
|
779
|
+
placeholder={placeholder || t('common:actions.search', 'Search...')}
|
|
780
|
+
disabled={disabled}
|
|
781
|
+
className="w-full pl-9 pr-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:ring-2 focus:ring-[var(--color-accent-500)] focus:border-transparent"
|
|
782
|
+
/>
|
|
783
|
+
</div>
|
|
784
|
+
)}
|
|
785
|
+
|
|
786
|
+
{/* Dropdown */}
|
|
787
|
+
{isOpen && (
|
|
788
|
+
<div className="absolute z-50 w-full mt-1 bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] shadow-lg max-h-60 overflow-auto">
|
|
789
|
+
{loading ? (
|
|
790
|
+
<div className="p-3 text-center text-[var(--text-secondary)]">
|
|
791
|
+
{t('common:actions.loading', 'Loading...')}
|
|
792
|
+
</div>
|
|
793
|
+
) : options.length === 0 ? (
|
|
794
|
+
<div className="p-3 text-center text-[var(--text-secondary)]">
|
|
795
|
+
{search.length < 2
|
|
796
|
+
? t('common:actions.typeToSearch', 'Type at least 2 characters to search...')
|
|
797
|
+
: t('common:empty.noResults', 'No results found')}
|
|
798
|
+
</div>
|
|
799
|
+
) : (
|
|
800
|
+
options.map((option) => (
|
|
801
|
+
<button
|
|
802
|
+
key={option.id}
|
|
803
|
+
type="button"
|
|
804
|
+
onClick={() => handleSelect(option)}
|
|
805
|
+
className="w-full px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
|
|
806
|
+
>
|
|
807
|
+
<div className="text-[var(--text-primary)]">{option.label}</div>
|
|
808
|
+
{option.sublabel && (
|
|
809
|
+
<div className="text-sm text-[var(--text-secondary)]">{option.sublabel}</div>
|
|
810
|
+
)}
|
|
811
|
+
</button>
|
|
812
|
+
))
|
|
813
|
+
)}
|
|
814
|
+
</div>
|
|
815
|
+
)}
|
|
816
|
+
|
|
817
|
+
{/* Error message */}
|
|
818
|
+
{error && (
|
|
819
|
+
<p className="mt-1 text-sm text-[var(--error-text)]">{error}</p>
|
|
820
|
+
)}
|
|
821
|
+
</div>
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
### Usage in Form Pages
|
|
827
|
+
|
|
828
|
+
```tsx
|
|
829
|
+
// In EntityCreatePage.tsx or EntityEditPage.tsx
|
|
830
|
+
import { EntityLookup } from '@/components/ui/EntityLookup';
|
|
831
|
+
|
|
832
|
+
// Inside the form:
|
|
833
|
+
<EntityLookup
|
|
834
|
+
apiEndpoint="/api/business/human-resources/employees"
|
|
835
|
+
value={formData.employeeId}
|
|
836
|
+
onChange={(id) => handleChange('employeeId', id)}
|
|
837
|
+
label={t('module:form.employee', 'Employee')}
|
|
838
|
+
placeholder={t('module:form.employeePlaceholder', 'Search for an employee...')}
|
|
839
|
+
mapOption={(emp) => ({
|
|
840
|
+
id: emp.id,
|
|
841
|
+
label: `${emp.firstName} ${emp.lastName}`,
|
|
842
|
+
sublabel: emp.department || emp.code,
|
|
843
|
+
})}
|
|
844
|
+
required
|
|
845
|
+
error={errors.employeeId}
|
|
846
|
+
/>
|
|
847
|
+
|
|
848
|
+
// For DepartmentId FK:
|
|
849
|
+
<EntityLookup
|
|
850
|
+
apiEndpoint="/api/business/human-resources/departments"
|
|
851
|
+
value={formData.departmentId}
|
|
852
|
+
onChange={(id) => handleChange('departmentId', id)}
|
|
853
|
+
label={t('module:form.department', 'Department')}
|
|
854
|
+
placeholder={t('module:form.departmentPlaceholder', 'Search for a department...')}
|
|
855
|
+
mapOption={(dept) => ({
|
|
856
|
+
id: dept.id,
|
|
857
|
+
label: dept.name,
|
|
858
|
+
sublabel: dept.code,
|
|
859
|
+
})}
|
|
860
|
+
required
|
|
861
|
+
/>
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
### API Search Endpoint Convention (Backend)
|
|
865
|
+
|
|
866
|
+
For EntityLookup to work, each entity's API MUST support search via query parameter:
|
|
867
|
+
|
|
868
|
+
```
|
|
869
|
+
GET /api/{resource}?search={term}&pageSize=20
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
Response format:
|
|
873
|
+
```json
|
|
874
|
+
{
|
|
875
|
+
"items": [
|
|
876
|
+
{ "id": "guid", "code": "EMP001", "name": "John Doe", ... }
|
|
877
|
+
],
|
|
878
|
+
"totalCount": 42
|
|
879
|
+
}
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
The backend service's `GetAllAsync` method should accept search parameters:
|
|
883
|
+
|
|
884
|
+
```csharp
|
|
885
|
+
public async Task<PaginatedResult<EntityResponseDto>> GetAllAsync(
|
|
886
|
+
string? search = null,
|
|
887
|
+
int page = 1,
|
|
888
|
+
int pageSize = 20,
|
|
889
|
+
CancellationToken ct = default)
|
|
890
|
+
{
|
|
891
|
+
var query = _db.Entities
|
|
892
|
+
.Where(x => x.TenantId == _currentUser.TenantId);
|
|
893
|
+
|
|
894
|
+
if (!string.IsNullOrWhiteSpace(search))
|
|
895
|
+
{
|
|
896
|
+
query = query.Where(x =>
|
|
897
|
+
x.Name.Contains(search) ||
|
|
898
|
+
x.Code.Contains(search));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
var totalCount = await query.CountAsync(ct);
|
|
902
|
+
var items = await query
|
|
903
|
+
.OrderBy(x => x.Name)
|
|
904
|
+
.Skip((page - 1) * pageSize)
|
|
905
|
+
.Take(pageSize)
|
|
906
|
+
.Select(x => new EntityResponseDto { ... })
|
|
907
|
+
.ToListAsync(ct);
|
|
908
|
+
|
|
909
|
+
return new PaginatedResult<EntityResponseDto>(items, totalCount, page, pageSize);
|
|
910
|
+
}
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
### Rules
|
|
914
|
+
|
|
915
|
+
- **NEVER** render a `Guid` FK field as `<input type="text">` — always use `EntityLookup`
|
|
916
|
+
- **NEVER** ask the user to manually type or paste a GUID/ID
|
|
917
|
+
- **ALWAYS** provide a search-based selection for FK fields
|
|
918
|
+
- **ALWAYS** show the entity's display name (Name, FullName, Code+Name) not the GUID
|
|
919
|
+
- **ALWAYS** include `mapOption` to define how the related entity is displayed
|
|
920
|
+
- **ALWAYS** load the selected entity's display name on mount (for edit forms)
|
|
921
|
+
- **ALWAYS** support clearing the selection (unless required + already set)
|
|
922
|
+
|
|
923
|
+
**FORBIDDEN:**
|
|
924
|
+
```tsx
|
|
925
|
+
// WRONG: Plain text input for FK field
|
|
926
|
+
<input
|
|
927
|
+
type="text"
|
|
928
|
+
value={formData.employeeId}
|
|
929
|
+
onChange={(e) => handleChange('employeeId', e.target.value)}
|
|
930
|
+
placeholder="Enter Employee ID..."
|
|
931
|
+
/>
|
|
932
|
+
|
|
933
|
+
// WRONG: Raw GUID displayed to user
|
|
934
|
+
<span>{entity.departmentId}</span>
|
|
935
|
+
|
|
936
|
+
// WRONG: Select with hardcoded options for FK
|
|
937
|
+
<select onChange={(e) => handleChange('departmentId', e.target.value)}>
|
|
938
|
+
<option value="guid-1">Department A</option>
|
|
939
|
+
</select>
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
### I18n Keys for EntityLookup
|
|
943
|
+
|
|
944
|
+
Add these keys to the module's translation files:
|
|
945
|
+
|
|
946
|
+
```json
|
|
947
|
+
{
|
|
948
|
+
"form": {
|
|
949
|
+
"employee": "Employee",
|
|
950
|
+
"employeePlaceholder": "Search for an employee...",
|
|
951
|
+
"department": "Department",
|
|
952
|
+
"departmentPlaceholder": "Search for a department..."
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
---
|
|
958
|
+
|
|
959
|
+
## 7. Checklist for /apex Frontend Execution
|
|
960
|
+
|
|
961
|
+
Before marking frontend tasks as complete, verify:
|
|
962
|
+
|
|
963
|
+
- [ ] All page imports use `React.lazy()` with named export wrapping
|
|
964
|
+
- [ ] `<Suspense fallback={<PageLoader />}>` wraps all lazy components in routes
|
|
965
|
+
- [ ] Translation files exist for **all 4 languages** (fr, en, it, de)
|
|
966
|
+
- [ ] All `t()` calls include namespace prefix AND fallback value
|
|
967
|
+
- [ ] No hardcoded strings in JSX — all text goes through `t()`
|
|
968
|
+
- [ ] CSS uses variables only — no hardcoded Tailwind colors
|
|
969
|
+
- [ ] Pages follow loading → error → content pattern
|
|
970
|
+
- [ ] Pages use `src/pages/{Context}/{App}/{Module}/` hierarchy
|
|
971
|
+
- [ ] API calls use generated hooks or `apiClient` (never raw axios)
|
|
972
|
+
- [ ] Components use SmartTable/SmartFilter/EntityCard (never raw HTML tables)
|
|
973
|
+
- [ ] **FK fields use `EntityLookup` — ZERO plain text inputs for Guid FK fields**
|
|
974
|
+
- [ ] **All FK fields have `mapOption` showing display name, not GUID**
|
|
975
|
+
- [ ] **Backend APIs support `?search=` query parameter for EntityLookup**
|
|
976
|
+
- [ ] **Create/Edit forms are full pages with own routes — ZERO modals/popups/drawers**
|
|
977
|
+
- [ ] `EntityCreatePage.tsx` exists with route `/{module}/create`
|
|
978
|
+
- [ ] `EntityEditPage.tsx` exists with route `/{module}/:id/edit`
|
|
979
|
+
- [ ] No `<Modal>`, `<Dialog>`, `<Drawer>` imports in form-related pages
|
|
980
|
+
- [ ] Form pages include back button with `navigate(-1)`
|
|
981
|
+
- [ ] Form pages are covered by frontend tests (see section 8)
|
|
982
|
+
|
|
983
|
+
---
|
|
984
|
+
|
|
985
|
+
## 8. Frontend Form Testing
|
|
986
|
+
|
|
987
|
+
> **ALL form pages MUST have tests.** Forms are critical user interaction points and MUST be verified.
|
|
988
|
+
|
|
989
|
+
### Required Test Coverage per Form Page
|
|
990
|
+
|
|
991
|
+
| Test category | What to verify | Tool |
|
|
992
|
+
|---------------|---------------|------|
|
|
993
|
+
| Rendering | Form renders with all expected fields | Vitest + React Testing Library |
|
|
994
|
+
| Validation | Required fields show errors on empty submit | Vitest + React Testing Library |
|
|
995
|
+
| Submission | Successful submit calls API and navigates back | Vitest + MSW (mock API) |
|
|
996
|
+
| Pre-fill (edit) | Edit form loads entity data into fields | Vitest + React Testing Library |
|
|
997
|
+
| Navigation | Back button calls `navigate(-1)` | Vitest + React Testing Library |
|
|
998
|
+
| Error handling | API error displays error message | Vitest + MSW |
|
|
999
|
+
|
|
1000
|
+
### Test File Convention
|
|
1001
|
+
|
|
1002
|
+
```
|
|
1003
|
+
src/pages/{Context}/{App}/{Module}/
|
|
1004
|
+
├── EntityCreatePage.tsx
|
|
1005
|
+
├── EntityCreatePage.test.tsx ← MANDATORY
|
|
1006
|
+
├── EntityEditPage.tsx
|
|
1007
|
+
├── EntityEditPage.test.tsx ← MANDATORY
|
|
1008
|
+
├── EntityListPage.tsx
|
|
1009
|
+
└── EntityDetailPage.tsx
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
### Create Page Test Template
|
|
1013
|
+
|
|
1014
|
+
```tsx
|
|
1015
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
1016
|
+
import userEvent from '@testing-library/user-event';
|
|
1017
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
1018
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
1019
|
+
import { EntityCreatePage } from './EntityCreatePage';
|
|
1020
|
+
|
|
1021
|
+
// Mock API
|
|
1022
|
+
vi.mock('@/services/api/apiClient');
|
|
1023
|
+
const mockNavigate = vi.fn();
|
|
1024
|
+
vi.mock('react-router-dom', async () => ({
|
|
1025
|
+
...(await vi.importActual('react-router-dom')),
|
|
1026
|
+
useNavigate: () => mockNavigate,
|
|
1027
|
+
}));
|
|
1028
|
+
|
|
1029
|
+
describe('EntityCreatePage', () => {
|
|
1030
|
+
it('renders the create form with all fields', () => {
|
|
1031
|
+
render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
|
|
1032
|
+
expect(screen.getByRole('textbox', { name: /name/i })).toBeInTheDocument();
|
|
1033
|
+
// Verify all expected form fields
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it('shows validation errors on empty submit', async () => {
|
|
1037
|
+
render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
|
|
1038
|
+
await userEvent.click(screen.getByRole('button', { name: /save|create/i }));
|
|
1039
|
+
await waitFor(() => {
|
|
1040
|
+
expect(screen.getByText(/required/i)).toBeInTheDocument();
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it('submits form and navigates back on success', async () => {
|
|
1045
|
+
render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
|
|
1046
|
+
await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Test');
|
|
1047
|
+
await userEvent.click(screen.getByRole('button', { name: /save|create/i }));
|
|
1048
|
+
await waitFor(() => {
|
|
1049
|
+
expect(mockNavigate).toHaveBeenCalledWith(-1);
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('navigates back on cancel/back button', async () => {
|
|
1054
|
+
render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
|
|
1055
|
+
await userEvent.click(screen.getByRole('button', { name: /back|cancel/i }));
|
|
1056
|
+
expect(mockNavigate).toHaveBeenCalledWith(-1);
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
### Edit Page Test Template
|
|
1062
|
+
|
|
1063
|
+
```tsx
|
|
1064
|
+
describe('EntityEditPage', () => {
|
|
1065
|
+
it('loads entity data and pre-fills the form', async () => {
|
|
1066
|
+
render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
|
|
1067
|
+
await waitFor(() => {
|
|
1068
|
+
expect(screen.getByDisplayValue('Existing Name')).toBeInTheDocument();
|
|
1069
|
+
});
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
it('submits updated data and navigates back', async () => {
|
|
1073
|
+
render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
|
|
1074
|
+
await waitFor(() => screen.getByDisplayValue('Existing Name'));
|
|
1075
|
+
await userEvent.clear(screen.getByRole('textbox', { name: /name/i }));
|
|
1076
|
+
await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Updated');
|
|
1077
|
+
await userEvent.click(screen.getByRole('button', { name: /save/i }));
|
|
1078
|
+
await waitFor(() => {
|
|
1079
|
+
expect(mockNavigate).toHaveBeenCalledWith(-1);
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it('displays error when API call fails', async () => {
|
|
1084
|
+
// Mock API to reject
|
|
1085
|
+
render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
|
|
1086
|
+
// ... trigger submit with mocked failure
|
|
1087
|
+
await waitFor(() => {
|
|
1088
|
+
expect(screen.getByText(/failed/i)).toBeInTheDocument();
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
### Rules
|
|
1095
|
+
|
|
1096
|
+
- **EVERY** `EntityCreatePage.tsx` MUST have a companion `EntityCreatePage.test.tsx`
|
|
1097
|
+
- **EVERY** `EntityEditPage.tsx` MUST have a companion `EntityEditPage.test.tsx`
|
|
1098
|
+
- Tests MUST cover: rendering, validation, submit success, submit error, navigation
|
|
1099
|
+
- Use `@testing-library/react` + `@testing-library/user-event` (NEVER enzyme)
|
|
1100
|
+
- Mock API with `vi.mock()` or MSW — NEVER make real API calls in tests
|
|
1101
|
+
- Test files live next to their component (co-located, NOT in a separate `__tests__/` folder)
|