@clipboard-health/ai-rules 1.1.1 → 1.2.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/frontend/AGENTS.md +448 -3595
- package/frontend/CLAUDE.md +448 -3595
- package/fullstack/AGENTS.md +448 -3595
- package/fullstack/CLAUDE.md +448 -3595
- package/package.json +2 -2
package/fullstack/AGENTS.md
CHANGED
|
@@ -227,328 +227,80 @@ Send notifications through [Knock](https://docs.knock.app) using the `@clipboard
|
|
|
227
227
|
## Hook Structure
|
|
228
228
|
|
|
229
229
|
```typescript
|
|
230
|
-
interface UseFeatureOptions {
|
|
231
|
-
enabled?: boolean;
|
|
232
|
-
refetchInterval?: number;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
230
|
export function useFeature(params: Params, options: UseFeatureOptions = {}) {
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// Derived state
|
|
243
|
-
const computed = useMemo(() => {
|
|
244
|
-
// Combine data
|
|
245
|
-
return transformData(query1.data, query2.data);
|
|
246
|
-
}, [query1.data, query2.data]);
|
|
247
|
-
|
|
248
|
-
// Callbacks
|
|
249
|
-
const handleAction = useCallback(async () => {
|
|
250
|
-
// Perform action
|
|
251
|
-
}, [dependencies]);
|
|
252
|
-
|
|
253
|
-
// Return structured object
|
|
254
|
-
return {
|
|
255
|
-
// Data
|
|
256
|
-
data: computed,
|
|
257
|
-
|
|
258
|
-
// Loading states
|
|
259
|
-
isLoading: query1.isLoading || query2.isLoading,
|
|
260
|
-
isError: query1.isError || query2.isError,
|
|
261
|
-
|
|
262
|
-
// Actions
|
|
263
|
-
refetch: async () => {
|
|
264
|
-
await Promise.all([query1.refetch(), query2.refetch()]);
|
|
265
|
-
},
|
|
266
|
-
handleAction,
|
|
267
|
-
};
|
|
231
|
+
const query = useQuery(...);
|
|
232
|
+
const computed = useMemo(() => transformData(query.data), [query.data]);
|
|
233
|
+
const handleAction = useCallback(async () => { /* ... */ }, []);
|
|
234
|
+
|
|
235
|
+
return { data: computed, isLoading: query.isLoading, handleAction };
|
|
268
236
|
}
|
|
269
237
|
```
|
|
270
238
|
|
|
271
239
|
## Naming Rules
|
|
272
240
|
|
|
273
241
|
- **Always** prefix with `use`
|
|
274
|
-
- Boolean
|
|
275
|
-
- Data
|
|
276
|
-
-
|
|
242
|
+
- Boolean: Use `useIs*`, `useHas*`, or `useCan*` (e.g., `useIsEnabled`, `useHasPermission`, `useCanEdit`)
|
|
243
|
+
- Data: `useGetUser`, `useFeatureData`
|
|
244
|
+
- Actions: `useSubmitForm`
|
|
277
245
|
|
|
278
246
|
## Return Values
|
|
279
247
|
|
|
280
248
|
- Return objects (not arrays) for complex hooks
|
|
281
|
-
- Name
|
|
282
|
-
- Group related properties together
|
|
283
|
-
|
|
284
|
-
```typescript
|
|
285
|
-
// Good - clear property names
|
|
286
|
-
return {
|
|
287
|
-
data,
|
|
288
|
-
isLoading,
|
|
289
|
-
isError,
|
|
290
|
-
refetch,
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
// Avoid - array destructuring for complex returns
|
|
294
|
-
return [data, isLoading, refetch]; // Only for very simple hooks
|
|
295
|
-
```
|
|
249
|
+
- Name clearly: `isLoading` not `loading`
|
|
296
250
|
|
|
297
251
|
## State Management with Constate
|
|
298
252
|
|
|
299
|
-
|
|
253
|
+
Use `constate` for shared state between components:
|
|
300
254
|
|
|
301
255
|
```typescript
|
|
302
256
|
import constate from "constate";
|
|
303
|
-
import { useState, useCallback } from "react";
|
|
304
257
|
|
|
305
|
-
|
|
306
|
-
function useShiftFilters() {
|
|
258
|
+
function useFilters() {
|
|
307
259
|
const [filters, setFilters] = useState<Filters>({});
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const applyFilters = useCallback((newFilters: Filters) => {
|
|
311
|
-
setFilters(newFilters);
|
|
312
|
-
}, []);
|
|
313
|
-
|
|
314
|
-
const clearFilters = useCallback(() => {
|
|
315
|
-
setFilters({});
|
|
316
|
-
}, []);
|
|
317
|
-
|
|
318
|
-
return {
|
|
319
|
-
filters,
|
|
320
|
-
isLoading,
|
|
321
|
-
applyFilters,
|
|
322
|
-
clearFilters,
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Create provider and hook with constate
|
|
327
|
-
export const [ShiftFiltersProvider, useShiftFiltersContext] = constate(useShiftFilters);
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
**Usage:**
|
|
331
|
-
|
|
332
|
-
```typescript
|
|
333
|
-
// Wrap components that need access
|
|
334
|
-
function ShiftsPage() {
|
|
335
|
-
return (
|
|
336
|
-
<ShiftFiltersProvider>
|
|
337
|
-
<ShiftList />
|
|
338
|
-
<FilterPanel />
|
|
339
|
-
</ShiftFiltersProvider>
|
|
340
|
-
);
|
|
260
|
+
return { filters, setFilters };
|
|
341
261
|
}
|
|
342
262
|
|
|
343
|
-
|
|
344
|
-
function ShiftList() {
|
|
345
|
-
const { filters, applyFilters } = useShiftFiltersContext();
|
|
346
|
-
|
|
347
|
-
// Use shared state
|
|
348
|
-
return <div>{/* ... */}</div>;
|
|
349
|
-
}
|
|
263
|
+
export const [FiltersProvider, useFiltersContext] = constate(useFilters);
|
|
350
264
|
```
|
|
351
265
|
|
|
352
|
-
**
|
|
353
|
-
|
|
354
|
-
- Minimal boilerplate compared to raw Context API
|
|
355
|
-
- TypeScript support out of the box
|
|
356
|
-
- Avoids prop drilling
|
|
357
|
-
- Clean separation of state logic
|
|
358
|
-
|
|
359
|
-
**When to use Constate:**
|
|
360
|
-
|
|
361
|
-
- Sharing state between multiple sibling components
|
|
362
|
-
- Complex feature-level state (filters, UI state, form state)
|
|
363
|
-
- Alternative to prop drilling
|
|
364
|
-
|
|
365
|
-
**When NOT to use Constate:**
|
|
366
|
-
|
|
367
|
-
- Server state (use React Query instead)
|
|
368
|
-
- Global app state (consider if it's truly needed)
|
|
369
|
-
- Simple parent-child communication (use props)
|
|
266
|
+
**When to use:** Sharing state between siblings, feature-level state
|
|
267
|
+
**When NOT:** Server state (use React Query), simple parent-child (use props)
|
|
370
268
|
|
|
371
|
-
##
|
|
269
|
+
## Hook Patterns
|
|
372
270
|
|
|
373
271
|
```typescript
|
|
374
|
-
//
|
|
272
|
+
// Boolean hooks
|
|
375
273
|
export function useIsFeatureEnabled(): boolean {
|
|
376
|
-
|
|
377
|
-
return flags.includes("new-feature");
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
export function useHasPermission(permission: string): boolean {
|
|
381
|
-
const user = useUser();
|
|
382
|
-
return user.permissions.includes(permission);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
export function useShouldShowModal(): boolean {
|
|
386
|
-
const hasSeenModal = usePreference("hasSeenModal");
|
|
387
|
-
return !hasSeenModal;
|
|
274
|
+
return useFeatureFlags().includes("feature");
|
|
388
275
|
}
|
|
389
|
-
```
|
|
390
276
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
```typescript
|
|
277
|
+
// Data transformation
|
|
394
278
|
export function useTransformedData() {
|
|
395
|
-
const { data
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (!rawData) return undefined;
|
|
399
|
-
|
|
400
|
-
return rawData.map((item) => ({
|
|
401
|
-
...item,
|
|
402
|
-
displayName: formatName(item),
|
|
403
|
-
}));
|
|
404
|
-
}, [rawData]);
|
|
405
|
-
|
|
406
|
-
return {
|
|
407
|
-
data: transformedData,
|
|
408
|
-
isLoading,
|
|
409
|
-
isError,
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
## Side Effect Hooks
|
|
415
|
-
|
|
416
|
-
```typescript
|
|
417
|
-
// Hooks that perform side effects (logging, tracking, etc.)
|
|
418
|
-
export function useTrackPageView(pageName: string) {
|
|
419
|
-
useEffect(() => {
|
|
420
|
-
logEvent("PAGE_VIEWED", { pageName });
|
|
421
|
-
}, [pageName]);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Usage
|
|
425
|
-
export function MyPage() {
|
|
426
|
-
useTrackPageView("MyPage");
|
|
427
|
-
// ... rest of component
|
|
428
|
-
}
|
|
429
|
-
```
|
|
430
|
-
|
|
431
|
-
## Composite Hooks
|
|
432
|
-
|
|
433
|
-
```typescript
|
|
434
|
-
// Combines multiple hooks and data sources
|
|
435
|
-
export function useWorkerBookingsData() {
|
|
436
|
-
const worker = useDefinedWorker();
|
|
437
|
-
const isFeatureEnabled = useIsFeatureEnabled("new-bookings");
|
|
438
|
-
|
|
439
|
-
const {
|
|
440
|
-
data: shifts,
|
|
441
|
-
isLoading: isLoadingShifts,
|
|
442
|
-
refetch: refetchShifts,
|
|
443
|
-
} = useGetShifts(worker.id);
|
|
444
|
-
|
|
445
|
-
const {
|
|
446
|
-
data: invites,
|
|
447
|
-
isLoading: isLoadingInvites,
|
|
448
|
-
refetch: refetchInvites,
|
|
449
|
-
} = useGetInvites(worker.id, { enabled: isFeatureEnabled });
|
|
450
|
-
|
|
451
|
-
// Combine data
|
|
452
|
-
const bookings = useMemo(() => {
|
|
453
|
-
return [...(shifts ?? []), ...(invites ?? [])].sort(sortByDate);
|
|
454
|
-
}, [shifts, invites]);
|
|
455
|
-
|
|
456
|
-
// Combined loading state
|
|
457
|
-
const isLoading = isLoadingShifts || isLoadingInvites;
|
|
458
|
-
|
|
459
|
-
// Combined refetch
|
|
460
|
-
async function refreshAllData() {
|
|
461
|
-
await Promise.all([refetchShifts(), refetchInvites()]);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
return {
|
|
465
|
-
// Data
|
|
466
|
-
bookings,
|
|
467
|
-
|
|
468
|
-
// States
|
|
469
|
-
isLoading,
|
|
470
|
-
isFeatureEnabled,
|
|
471
|
-
|
|
472
|
-
// Actions
|
|
473
|
-
refreshAllData,
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
## Co-location
|
|
479
|
-
|
|
480
|
-
- Place hooks in `hooks/` folder within feature directory
|
|
481
|
-
- Place API hooks in `api/` folder
|
|
482
|
-
- Keep generic/shared hooks in `lib/` or `utils/`
|
|
483
|
-
|
|
484
|
-
```text
|
|
485
|
-
FeatureName/
|
|
486
|
-
├── api/
|
|
487
|
-
│ ├── useGetFeature.ts # API data fetching
|
|
488
|
-
│ └── useUpdateFeature.ts
|
|
489
|
-
├── hooks/
|
|
490
|
-
│ ├── useFeatureLogic.ts # Business logic hooks
|
|
491
|
-
│ ├── useFeatureState.ts # Constate state hooks
|
|
492
|
-
│ └── useFeatureFilters.ts
|
|
493
|
-
└── components/
|
|
494
|
-
└── FeatureComponent.tsx
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
## Options Pattern
|
|
498
|
-
|
|
499
|
-
```typescript
|
|
500
|
-
// Always use options object for flexibility
|
|
501
|
-
interface UseFeatureOptions {
|
|
502
|
-
enabled?: boolean;
|
|
503
|
-
refetchInterval?: number;
|
|
504
|
-
onSuccess?: (data: Data) => void;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
export function useFeature(params: Params, options: UseFeatureOptions = {}) {
|
|
508
|
-
const { enabled = true, refetchInterval, onSuccess } = options;
|
|
509
|
-
|
|
510
|
-
// Use options in queries
|
|
511
|
-
return useQuery({
|
|
512
|
-
enabled,
|
|
513
|
-
refetchInterval,
|
|
514
|
-
onSuccess,
|
|
515
|
-
// ...
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
```
|
|
519
|
-
|
|
520
|
-
## Hook Dependencies
|
|
521
|
-
|
|
522
|
-
```typescript
|
|
523
|
-
// Be explicit about dependencies
|
|
524
|
-
export function useFormattedData(rawData: Data[]) {
|
|
525
|
-
return useMemo(() => {
|
|
526
|
-
return rawData.map(format);
|
|
527
|
-
}, [rawData]); // Clear dependency
|
|
279
|
+
const { data, isLoading } = useGetRawData();
|
|
280
|
+
const transformed = useMemo(() => data?.map(format), [data]);
|
|
281
|
+
return { data: transformed, isLoading };
|
|
528
282
|
}
|
|
529
283
|
|
|
530
|
-
//
|
|
531
|
-
export function
|
|
532
|
-
const
|
|
284
|
+
// Composite hooks
|
|
285
|
+
export function useBookingsData() {
|
|
286
|
+
const shifts = useGetShifts();
|
|
287
|
+
const invites = useGetInvites();
|
|
533
288
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
289
|
+
const combined = useMemo(
|
|
290
|
+
() => [...(shifts.data ?? []), ...(invites.data ?? [])],
|
|
291
|
+
[shifts.data, invites.data],
|
|
292
|
+
);
|
|
538
293
|
|
|
539
|
-
return
|
|
294
|
+
return { data: combined, isLoading: shifts.isLoading || invites.isLoading };
|
|
540
295
|
}
|
|
541
296
|
```
|
|
542
297
|
|
|
543
298
|
## Best Practices
|
|
544
299
|
|
|
545
|
-
-
|
|
546
|
-
-
|
|
547
|
-
-
|
|
548
|
-
-
|
|
549
|
-
- **Clear property names** - `isLoading`, not `loading`
|
|
550
|
-
- **Options pattern** - For flexible configuration
|
|
551
|
-
- **Explicit dependencies** - In useMemo/useCallback
|
|
300
|
+
- Use options object for flexibility
|
|
301
|
+
- Be explicit about dependencies
|
|
302
|
+
- API hooks in `api/`, logic hooks in `hooks/`
|
|
303
|
+
- Return stable references with `useCallback`/`useMemo`
|
|
552
304
|
|
|
553
305
|
<!-- Source: .ruler/frontend/data-fetching.md -->
|
|
554
306
|
|
|
@@ -557,321 +309,98 @@ export function useHandler() {
|
|
|
557
309
|
## Technology Stack
|
|
558
310
|
|
|
559
311
|
- **React Query** (@tanstack/react-query) for all API calls
|
|
560
|
-
- **Axios** for HTTP requests
|
|
561
312
|
- **Zod** for response validation
|
|
562
313
|
|
|
563
314
|
## Core Principles
|
|
564
315
|
|
|
565
316
|
1. **Use URL and query parameters in query keys** - Makes cache invalidation predictable
|
|
566
|
-
2. **
|
|
567
|
-
3. **
|
|
568
|
-
4. **
|
|
569
|
-
5. **
|
|
570
|
-
6. **Use `enabled` for conditional fetching** - With `isDefined()` helper
|
|
571
|
-
7. **Use `invalidateQueries` for disabled queries** - Not `refetch()` which ignores enabled state
|
|
317
|
+
2. **Define Zod schemas** for all API requests/responses
|
|
318
|
+
3. **Rely on React Query state** - Use `isLoading`, `isError`, `isSuccess`
|
|
319
|
+
4. **Use `enabled` for conditional fetching**
|
|
320
|
+
5. **Use `invalidateQueries` for disabled queries** - Not `refetch()` which ignores enabled state
|
|
572
321
|
|
|
573
322
|
## Hook Patterns
|
|
574
323
|
|
|
575
|
-
### Simple Query
|
|
576
|
-
|
|
577
324
|
```typescript
|
|
325
|
+
// Simple query
|
|
578
326
|
export function useGetUser(userId: string) {
|
|
579
|
-
return
|
|
580
|
-
|
|
327
|
+
return useQuery({
|
|
328
|
+
queryKey: ["users", userId],
|
|
329
|
+
queryFn: () => api.get(`/users/${userId}`),
|
|
581
330
|
responseSchema: userSchema,
|
|
582
|
-
enabled:
|
|
583
|
-
staleTime: minutesToMilliseconds(5),
|
|
584
|
-
meta: {
|
|
585
|
-
logErrorMessage: APP_V2_APP_EVENTS.GET_USER_FAILURE,
|
|
586
|
-
},
|
|
331
|
+
enabled: !!userId,
|
|
587
332
|
});
|
|
588
333
|
}
|
|
589
|
-
```
|
|
590
334
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
```typescript
|
|
594
|
-
export function usePaginatedShifts(params: Params) {
|
|
335
|
+
// Paginated query
|
|
336
|
+
export function usePaginatedItems(params: Params) {
|
|
595
337
|
return useInfiniteQuery({
|
|
596
|
-
queryKey: ["
|
|
597
|
-
queryFn:
|
|
598
|
-
|
|
599
|
-
url: "/api/shifts",
|
|
600
|
-
queryParams: { cursor: pageParam, ...params },
|
|
601
|
-
responseSchema: shiftsResponseSchema,
|
|
602
|
-
});
|
|
603
|
-
return response.data;
|
|
604
|
-
},
|
|
605
|
-
getNextPageParam: (lastPage) => lastPage.links.nextCursor,
|
|
338
|
+
queryKey: ["items", params],
|
|
339
|
+
queryFn: ({ pageParam }) => api.get("/items", { cursor: pageParam, ...params }),
|
|
340
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
606
341
|
});
|
|
607
342
|
}
|
|
608
|
-
```
|
|
609
|
-
|
|
610
|
-
### Composite Data Fetching
|
|
611
343
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const bookings = useMemo(() => {
|
|
620
|
-
return [...(shifts ?? []), ...(invites ?? [])];
|
|
621
|
-
}, [shifts, invites]);
|
|
622
|
-
|
|
623
|
-
// Combine loading states
|
|
624
|
-
const isLoading = isLoadingShifts || isLoadingInvites;
|
|
625
|
-
|
|
626
|
-
// Combine refetch functions
|
|
627
|
-
async function refreshAllData() {
|
|
628
|
-
await Promise.all([refetchShifts(), refetchInvites()]);
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
return {
|
|
632
|
-
bookings,
|
|
633
|
-
isLoading,
|
|
634
|
-
refreshAllData,
|
|
635
|
-
};
|
|
344
|
+
// Mutations
|
|
345
|
+
export function useCreateItem() {
|
|
346
|
+
const queryClient = useQueryClient();
|
|
347
|
+
return useMutation({
|
|
348
|
+
mutationFn: (data: CreateItemRequest) => api.post("/items", data),
|
|
349
|
+
onSuccess: () => queryClient.invalidateQueries(["items"]),
|
|
350
|
+
});
|
|
636
351
|
}
|
|
637
352
|
```
|
|
638
353
|
|
|
639
354
|
## Error Handling
|
|
640
355
|
|
|
641
|
-
|
|
356
|
+
For detailed error handling strategies, see `error-handling.md`.
|
|
642
357
|
|
|
643
358
|
```typescript
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
meta: {
|
|
648
|
-
logErrorMessage: APP_V2_APP_EVENTS.GET_RESOURCE_FAILURE, // ✅ Centralized
|
|
649
|
-
userErrorMessage: "Failed to load data", // Shows alert to user
|
|
650
|
-
},
|
|
359
|
+
useQuery({
|
|
360
|
+
queryKey: ["resource"],
|
|
361
|
+
queryFn: fetchResource,
|
|
651
362
|
useErrorBoundary: (error) => {
|
|
652
|
-
//
|
|
363
|
+
// Show error boundary for 500s, not 404s
|
|
653
364
|
return !(axios.isAxiosError(error) && error.response?.status === 404);
|
|
654
365
|
},
|
|
655
366
|
retry: (failureCount, error) => {
|
|
656
|
-
// Don't retry
|
|
657
|
-
if (axios.isAxiosError(error))
|
|
658
|
-
const status = error.response?.status;
|
|
659
|
-
return ![404, 401].includes(status ?? 0);
|
|
660
|
-
}
|
|
367
|
+
// Don't retry 4xx errors
|
|
368
|
+
if (axios.isAxiosError(error) && error.response?.status < 500) return false;
|
|
661
369
|
return failureCount < 3;
|
|
662
370
|
},
|
|
663
371
|
});
|
|
664
372
|
```
|
|
665
373
|
|
|
666
|
-
## State Management - Don't Reinvent the Wheel
|
|
667
|
-
|
|
668
|
-
❌ **Don't** create your own loading/error state:
|
|
669
|
-
|
|
670
|
-
```typescript
|
|
671
|
-
const [data, setData] = useState();
|
|
672
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
673
|
-
const [error, setError] = useState();
|
|
674
|
-
|
|
675
|
-
useEffect(() => {
|
|
676
|
-
async function fetchData() {
|
|
677
|
-
try {
|
|
678
|
-
setIsLoading(true);
|
|
679
|
-
const result = await api.get("/data");
|
|
680
|
-
setData(result);
|
|
681
|
-
} catch (err) {
|
|
682
|
-
setError(err);
|
|
683
|
-
} finally {
|
|
684
|
-
setIsLoading(false);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
fetchData();
|
|
688
|
-
}, []);
|
|
689
|
-
```
|
|
690
|
-
|
|
691
|
-
✅ **Do** use React Query states:
|
|
692
|
-
|
|
693
|
-
```typescript
|
|
694
|
-
const { data, isLoading, isError, isSuccess } = useGetQuery({...});
|
|
695
|
-
|
|
696
|
-
if (isLoading) return <Loading />;
|
|
697
|
-
if (isError) return <Error />;
|
|
698
|
-
if (isSuccess) return <div>{data.property}</div>;
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
## Mutations
|
|
702
|
-
|
|
703
|
-
```typescript
|
|
704
|
-
export function useCreateDocument() {
|
|
705
|
-
const queryClient = useQueryClient();
|
|
706
|
-
|
|
707
|
-
return useMutation({
|
|
708
|
-
mutationFn: async (data: CreateDocumentRequest) => {
|
|
709
|
-
return await post({
|
|
710
|
-
url: "/api/documents",
|
|
711
|
-
data,
|
|
712
|
-
responseSchema: documentSchema,
|
|
713
|
-
});
|
|
714
|
-
},
|
|
715
|
-
onSuccess: () => {
|
|
716
|
-
// Invalidate queries to refetch
|
|
717
|
-
queryClient.invalidateQueries(["documents"]);
|
|
718
|
-
},
|
|
719
|
-
onError: (error) => {
|
|
720
|
-
logEvent(APP_V2_APP_EVENTS.CREATE_DOCUMENT_FAILURE, { error });
|
|
721
|
-
},
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
```
|
|
725
|
-
|
|
726
374
|
## Query Keys
|
|
727
375
|
|
|
728
|
-
Always include URL and parameters in query keys:
|
|
729
|
-
|
|
730
376
|
```typescript
|
|
731
|
-
//
|
|
732
|
-
useQuery({ queryKey: "users", ... });
|
|
733
|
-
|
|
734
|
-
// ✅ Do include URL and params
|
|
735
|
-
useQuery({ queryKey: [`/api/users?${status}`, { status }], ... });
|
|
736
|
-
|
|
737
|
-
// Consistent query key structure
|
|
377
|
+
// Include URL and params for predictable cache invalidation
|
|
738
378
|
export const queryKeys = {
|
|
739
379
|
users: ["users"] as const,
|
|
740
380
|
user: (id: string) => ["users", id] as const,
|
|
741
|
-
|
|
381
|
+
userPosts: (id: string) => ["users", id, "posts"] as const,
|
|
742
382
|
};
|
|
743
|
-
|
|
744
|
-
// Usage
|
|
745
|
-
useQuery({
|
|
746
|
-
queryKey: queryKeys.user(userId),
|
|
747
|
-
// ...
|
|
748
|
-
});
|
|
749
|
-
```
|
|
750
|
-
|
|
751
|
-
## Refetch Intervals
|
|
752
|
-
|
|
753
|
-
```typescript
|
|
754
|
-
useGetQuery({
|
|
755
|
-
url: "/api/resource",
|
|
756
|
-
responseSchema: schema,
|
|
757
|
-
refetchInterval: (data) => {
|
|
758
|
-
// Dynamic refetch based on data state
|
|
759
|
-
if (!data?.isComplete) {
|
|
760
|
-
return 1000; // Poll every second until complete
|
|
761
|
-
}
|
|
762
|
-
return 0; // Stop refetching
|
|
763
|
-
},
|
|
764
|
-
});
|
|
765
383
|
```
|
|
766
384
|
|
|
767
385
|
## Conditional Fetching
|
|
768
386
|
|
|
769
|
-
Use `enabled` option with `isDefined()` helper:
|
|
770
|
-
|
|
771
|
-
```typescript
|
|
772
|
-
import { isDefined } from "@/lib/utils";
|
|
773
|
-
|
|
774
|
-
const { data } = useGetQuery({
|
|
775
|
-
url: "/api/resource",
|
|
776
|
-
responseSchema: schema,
|
|
777
|
-
// Only fetch when conditions are met
|
|
778
|
-
enabled: isDefined(userId) && isFeatureEnabled,
|
|
779
|
-
});
|
|
780
|
-
```
|
|
781
|
-
|
|
782
|
-
## Refetch vs InvalidateQueries
|
|
783
|
-
|
|
784
|
-
**Important:** For disabled queries, use `invalidateQueries` instead of `refetch`:
|
|
785
|
-
|
|
786
|
-
❌ **Don't** use `refetch()` on disabled queries:
|
|
787
|
-
|
|
788
387
|
```typescript
|
|
789
|
-
const {
|
|
790
|
-
|
|
791
|
-
|
|
388
|
+
const { data } = useQuery({
|
|
389
|
+
queryKey: ["resource", id],
|
|
390
|
+
queryFn: () => fetchResource(id),
|
|
391
|
+
enabled: !!id, // Only fetch when id exists
|
|
792
392
|
});
|
|
793
|
-
|
|
794
|
-
// Will fetch even if agentId is undefined!
|
|
795
|
-
refetch();
|
|
796
|
-
```
|
|
797
|
-
|
|
798
|
-
✅ **Do** use `invalidateQueries`:
|
|
799
|
-
|
|
800
|
-
```typescript
|
|
801
|
-
const queryClient = useQueryClient();
|
|
802
|
-
|
|
803
|
-
const { data } = useGetQuery({
|
|
804
|
-
enabled: isDefined(shift.agentId),
|
|
805
|
-
...
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
// Respects the enabled state
|
|
809
|
-
queryClient.invalidateQueries({ queryKey: [myQueryKey] });
|
|
810
|
-
```
|
|
811
|
-
|
|
812
|
-
## Query Cancellation
|
|
813
|
-
|
|
814
|
-
```typescript
|
|
815
|
-
export function usePaginatedData() {
|
|
816
|
-
const queryClient = useQueryClient();
|
|
817
|
-
|
|
818
|
-
return useInfiniteQuery({
|
|
819
|
-
queryKey: ["data"],
|
|
820
|
-
queryFn: async ({ pageParam }) => {
|
|
821
|
-
// Cancel previous in-flight requests
|
|
822
|
-
await queryClient.cancelQueries({ queryKey: ["data"] });
|
|
823
|
-
|
|
824
|
-
const response = await get({
|
|
825
|
-
url: "/api/data",
|
|
826
|
-
queryParams: { cursor: pageParam },
|
|
827
|
-
});
|
|
828
|
-
return response.data;
|
|
829
|
-
},
|
|
830
|
-
// ...
|
|
831
|
-
});
|
|
832
|
-
}
|
|
833
393
|
```
|
|
834
394
|
|
|
835
395
|
## Naming Conventions
|
|
836
396
|
|
|
837
|
-
-
|
|
838
|
-
-
|
|
839
|
-
-
|
|
840
|
-
- **Mutations**: `useCreateDocument`, `useUpdateShift`, `useDeletePlacement`
|
|
841
|
-
|
|
842
|
-
## Response Transformation
|
|
843
|
-
|
|
844
|
-
```typescript
|
|
845
|
-
export function useGetUser(userId: string) {
|
|
846
|
-
return useGetQuery({
|
|
847
|
-
url: `/api/users/${userId}`,
|
|
848
|
-
responseSchema: userResponseSchema,
|
|
849
|
-
select: (data) => {
|
|
850
|
-
// Transform response data
|
|
851
|
-
return {
|
|
852
|
-
...data,
|
|
853
|
-
fullName: `${data.firstName} ${data.lastName}`,
|
|
854
|
-
};
|
|
855
|
-
},
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
```
|
|
397
|
+
- `useGetX` - Simple queries
|
|
398
|
+
- `usePaginatedX` - Infinite queries
|
|
399
|
+
- `useCreateX`, `useUpdateX`, `useDeleteX` - Mutations
|
|
859
400
|
|
|
860
401
|
## Hook Location
|
|
861
402
|
|
|
862
|
-
|
|
863
|
-
- **One endpoint = one hook** principle
|
|
864
|
-
- Export types inferred from Zod: `export type User = z.infer<typeof userSchema>`
|
|
865
|
-
|
|
866
|
-
Example:
|
|
867
|
-
|
|
868
|
-
```text
|
|
869
|
-
Feature/
|
|
870
|
-
├── api/
|
|
871
|
-
│ ├── useGetResource.ts
|
|
872
|
-
│ ├── useCreateResource.ts
|
|
873
|
-
│ └── schemas.ts (optional)
|
|
874
|
-
```
|
|
403
|
+
Place in `api/` folder within feature directory. One endpoint = one hook.
|
|
875
404
|
|
|
876
405
|
<!-- Source: .ruler/frontend/error-handling.md -->
|
|
877
406
|
|
|
@@ -879,446 +408,104 @@ Feature/
|
|
|
879
408
|
|
|
880
409
|
## React Query Error Handling
|
|
881
410
|
|
|
882
|
-
### Basic Error Configuration
|
|
883
|
-
|
|
884
411
|
```typescript
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
meta: {
|
|
889
|
-
logErrorMessage: APP_V2_APP_EVENTS.GET_RESOURCE_FAILURE,
|
|
890
|
-
},
|
|
412
|
+
useQuery({
|
|
413
|
+
queryKey: ["resource"],
|
|
414
|
+
queryFn: fetchResource,
|
|
891
415
|
useErrorBoundary: (error) => {
|
|
892
|
-
// Show error boundary for 500s, not
|
|
416
|
+
// Show error boundary for 500s, not 404s
|
|
893
417
|
return !(axios.isAxiosError(error) && error.response?.status === 404);
|
|
894
418
|
},
|
|
895
419
|
retry: (failureCount, error) => {
|
|
896
|
-
// Don't retry 404s or 401s
|
|
897
|
-
if (axios.isAxiosError(error)) {
|
|
898
|
-
const status = error.response?.status;
|
|
899
|
-
return ![404, 401].includes(status ?? 0);
|
|
900
|
-
}
|
|
901
|
-
return failureCount < 3;
|
|
902
|
-
},
|
|
903
|
-
});
|
|
904
|
-
```
|
|
905
|
-
|
|
906
|
-
### Error Boundary Strategy
|
|
907
|
-
|
|
908
|
-
- **Show error boundary** for unexpected errors (500s, network failures)
|
|
909
|
-
- **Don't show error boundary** for expected errors (404s, validation errors)
|
|
910
|
-
- Use `useErrorBoundary` to control this behavior
|
|
911
|
-
|
|
912
|
-
```typescript
|
|
913
|
-
useErrorBoundary: (error) => {
|
|
914
|
-
// Only show error boundary for server errors
|
|
915
|
-
if (axios.isAxiosError(error)) {
|
|
916
|
-
const status = error.response?.status;
|
|
917
|
-
return status !== undefined && status >= 500;
|
|
918
|
-
}
|
|
919
|
-
return true; // Show for non-Axios errors
|
|
920
|
-
};
|
|
921
|
-
```
|
|
922
|
-
|
|
923
|
-
### Retry Configuration
|
|
924
|
-
|
|
925
|
-
```typescript
|
|
926
|
-
// Don't retry client errors
|
|
927
|
-
retry: (failureCount, error) => {
|
|
928
|
-
if (axios.isAxiosError(error)) {
|
|
929
|
-
const status = error.response?.status;
|
|
930
420
|
// Don't retry 4xx errors
|
|
931
|
-
if (
|
|
932
|
-
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
// Retry server errors up to 3 times
|
|
936
|
-
return failureCount < 3;
|
|
937
|
-
};
|
|
938
|
-
```
|
|
939
|
-
|
|
940
|
-
### Exponential Backoff
|
|
941
|
-
|
|
942
|
-
```typescript
|
|
943
|
-
import { type QueryClient } from "@tanstack/react-query";
|
|
944
|
-
|
|
945
|
-
const queryClient = new QueryClient({
|
|
946
|
-
defaultOptions: {
|
|
947
|
-
queries: {
|
|
948
|
-
retry: 3,
|
|
949
|
-
retryDelay: (attemptIndex) => {
|
|
950
|
-
// Exponential backoff: 1s, 2s, 4s
|
|
951
|
-
return Math.min(1000 * 2 ** attemptIndex, 30000);
|
|
952
|
-
},
|
|
953
|
-
},
|
|
421
|
+
if (axios.isAxiosError(error) && error.response?.status < 500) return false;
|
|
422
|
+
return failureCount < 3;
|
|
954
423
|
},
|
|
955
424
|
});
|
|
956
425
|
```
|
|
957
426
|
|
|
958
427
|
## Component Error States
|
|
959
428
|
|
|
960
|
-
### Pattern: Loading → Error → Success
|
|
961
|
-
|
|
962
429
|
```typescript
|
|
963
430
|
export function DataComponent() {
|
|
964
|
-
const { data, isLoading, isError,
|
|
431
|
+
const { data, isLoading, isError, refetch } = useGetData();
|
|
965
432
|
|
|
966
|
-
if (isLoading)
|
|
967
|
-
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
if (isError) {
|
|
971
|
-
return <ErrorState message="Failed to load data" onRetry={refetch} error={error} />;
|
|
972
|
-
}
|
|
433
|
+
if (isLoading) return <LoadingState />;
|
|
434
|
+
if (isError) return <ErrorState onRetry={refetch} />;
|
|
973
435
|
|
|
974
|
-
// Happy path
|
|
975
436
|
return <DataDisplay data={data} />;
|
|
976
437
|
}
|
|
977
438
|
```
|
|
978
439
|
|
|
979
|
-
### Inline Error Messages
|
|
980
|
-
|
|
981
|
-
```typescript
|
|
982
|
-
export function FormComponent() {
|
|
983
|
-
const mutation = useCreateResource();
|
|
984
|
-
|
|
985
|
-
return (
|
|
986
|
-
<form onSubmit={handleSubmit}>
|
|
987
|
-
{mutation.isError && <Alert severity="error">Failed to save. Please try again.</Alert>}
|
|
988
|
-
|
|
989
|
-
<Button type="submit" loading={mutation.isLoading} disabled={mutation.isLoading}>
|
|
990
|
-
Save
|
|
991
|
-
</Button>
|
|
992
|
-
</form>
|
|
993
|
-
);
|
|
994
|
-
}
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
### Graceful Degradation
|
|
998
|
-
|
|
999
|
-
```typescript
|
|
1000
|
-
export function OptionalDataComponent() {
|
|
1001
|
-
const { data, isError } = useGetOptionalData();
|
|
1002
|
-
|
|
1003
|
-
// Don't block UI for optional data
|
|
1004
|
-
if (isError) {
|
|
1005
|
-
logError("Failed to load optional data");
|
|
1006
|
-
return null; // or show simplified version
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
if (!data) {
|
|
1010
|
-
return null;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
return <EnhancedView data={data} />;
|
|
1014
|
-
}
|
|
1015
|
-
```
|
|
1016
|
-
|
|
1017
440
|
## Mutation Error Handling
|
|
1018
441
|
|
|
1019
|
-
### onError Callback
|
|
1020
|
-
|
|
1021
442
|
```typescript
|
|
1022
443
|
export function useCreateDocument() {
|
|
1023
|
-
const queryClient = useQueryClient();
|
|
1024
|
-
|
|
1025
444
|
return useMutation({
|
|
1026
445
|
mutationFn: createDocumentApi,
|
|
1027
|
-
onSuccess: (
|
|
446
|
+
onSuccess: () => {
|
|
1028
447
|
queryClient.invalidateQueries(["documents"]);
|
|
1029
448
|
showSuccessToast("Document created");
|
|
1030
449
|
},
|
|
1031
450
|
onError: (error) => {
|
|
1032
|
-
|
|
1033
|
-
error: error.message,
|
|
1034
|
-
});
|
|
451
|
+
logError("CREATE_DOCUMENT_FAILURE", error);
|
|
1035
452
|
showErrorToast("Failed to create document");
|
|
1036
453
|
},
|
|
1037
454
|
});
|
|
1038
455
|
}
|
|
1039
456
|
```
|
|
1040
457
|
|
|
1041
|
-
### Handling Specific Errors
|
|
1042
|
-
|
|
1043
|
-
```typescript
|
|
1044
|
-
export function useUpdateProfile() {
|
|
1045
|
-
return useMutation({
|
|
1046
|
-
mutationFn: updateProfileApi,
|
|
1047
|
-
onError: (error: AxiosError) => {
|
|
1048
|
-
if (error.response?.status === 409) {
|
|
1049
|
-
showErrorToast("Email already exists");
|
|
1050
|
-
} else if (error.response?.status === 422) {
|
|
1051
|
-
showErrorToast("Invalid data provided");
|
|
1052
|
-
} else {
|
|
1053
|
-
showErrorToast("Failed to update profile");
|
|
1054
|
-
}
|
|
1055
|
-
},
|
|
1056
|
-
});
|
|
1057
|
-
}
|
|
1058
|
-
```
|
|
1059
|
-
|
|
1060
|
-
## Logging and Monitoring
|
|
1061
|
-
|
|
1062
|
-
### Event Logging
|
|
1063
|
-
|
|
1064
|
-
```typescript
|
|
1065
|
-
import { logEvent } from '@/lib/analytics';
|
|
1066
|
-
import { APP_EVENTS } from '@/constants/events';
|
|
1067
|
-
|
|
1068
|
-
// In query configuration
|
|
1069
|
-
meta: {
|
|
1070
|
-
logErrorMessage: APP_V2_APP_EVENTS.GET_SHIFTS_FAILURE,
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
// In error handlers
|
|
1074
|
-
onError: (error) => {
|
|
1075
|
-
logEvent(APP_V2_APP_EVENTS.BOOKING_FAILED, {
|
|
1076
|
-
shiftId,
|
|
1077
|
-
error: error.message,
|
|
1078
|
-
userId: worker.id,
|
|
1079
|
-
});
|
|
1080
|
-
}
|
|
1081
|
-
```
|
|
1082
|
-
|
|
1083
|
-
### Error Context
|
|
1084
|
-
|
|
1085
|
-
Always include relevant context when logging errors:
|
|
1086
|
-
|
|
1087
|
-
```typescript
|
|
1088
|
-
logEvent(APP_V2_APP_EVENTS.API_ERROR, {
|
|
1089
|
-
endpoint: "/api/shifts",
|
|
1090
|
-
method: "GET",
|
|
1091
|
-
statusCode: error.response?.status,
|
|
1092
|
-
errorMessage: error.message,
|
|
1093
|
-
userId: worker.id,
|
|
1094
|
-
timestamp: new Date().toISOString(),
|
|
1095
|
-
});
|
|
1096
|
-
```
|
|
1097
|
-
|
|
1098
458
|
## Validation Errors
|
|
1099
459
|
|
|
1100
|
-
### Zod Validation
|
|
1101
|
-
|
|
1102
460
|
```typescript
|
|
1103
|
-
|
|
1104
|
-
|
|
461
|
+
// Zod validation
|
|
1105
462
|
const formSchema = z.object({
|
|
1106
|
-
email: z.string().email("Invalid email
|
|
1107
|
-
age: z.number().min(18, "Must be 18
|
|
463
|
+
email: z.string().email("Invalid email"),
|
|
464
|
+
age: z.number().min(18, "Must be 18+"),
|
|
1108
465
|
});
|
|
1109
466
|
|
|
1110
467
|
try {
|
|
1111
468
|
const validated = formSchema.parse(formData);
|
|
1112
|
-
// Use validated data
|
|
1113
469
|
} catch (error) {
|
|
1114
470
|
if (error instanceof z.ZodError) {
|
|
1115
|
-
|
|
1116
|
-
error.errors.forEach((err) => {
|
|
1117
|
-
showFieldError(err.path.join("."), err.message);
|
|
1118
|
-
});
|
|
471
|
+
error.errors.forEach((err) => showFieldError(err.path.join("."), err.message));
|
|
1119
472
|
}
|
|
1120
473
|
}
|
|
1121
474
|
```
|
|
1122
475
|
|
|
1123
|
-
|
|
476
|
+
## Error Boundaries
|
|
1124
477
|
|
|
1125
478
|
```typescript
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
message: string;
|
|
1129
|
-
}
|
|
479
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
480
|
+
state = { hasError: false };
|
|
1130
481
|
|
|
1131
|
-
|
|
1132
|
-
const validationErrors = error.response?.data?.errors as ApiValidationError[];
|
|
1133
|
-
|
|
1134
|
-
if (validationErrors) {
|
|
1135
|
-
validationErrors.forEach(({ field, message }) => {
|
|
1136
|
-
setFieldError(field, message);
|
|
1137
|
-
});
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
```
|
|
1141
|
-
|
|
1142
|
-
## Network Errors
|
|
1143
|
-
|
|
1144
|
-
### Offline Detection
|
|
1145
|
-
|
|
1146
|
-
```typescript
|
|
1147
|
-
export function useNetworkStatus() {
|
|
1148
|
-
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
|
1149
|
-
|
|
1150
|
-
useEffect(() => {
|
|
1151
|
-
function handleOnline() {
|
|
1152
|
-
setIsOnline(true);
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
function handleOffline() {
|
|
1156
|
-
setIsOnline(false);
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
window.addEventListener("online", handleOnline);
|
|
1160
|
-
window.addEventListener("offline", handleOffline);
|
|
1161
|
-
|
|
1162
|
-
return () => {
|
|
1163
|
-
window.removeEventListener("online", handleOnline);
|
|
1164
|
-
window.removeEventListener("offline", handleOffline);
|
|
1165
|
-
};
|
|
1166
|
-
}, []);
|
|
1167
|
-
|
|
1168
|
-
return isOnline;
|
|
1169
|
-
}
|
|
1170
|
-
```
|
|
1171
|
-
|
|
1172
|
-
### Offline UI
|
|
1173
|
-
|
|
1174
|
-
```typescript
|
|
1175
|
-
export function AppContainer() {
|
|
1176
|
-
const isOnline = useNetworkStatus();
|
|
1177
|
-
|
|
1178
|
-
return (
|
|
1179
|
-
<>
|
|
1180
|
-
{!isOnline && (
|
|
1181
|
-
<Banner severity="warning">You are offline. Some features may not be available.</Banner>
|
|
1182
|
-
)}
|
|
1183
|
-
<App />
|
|
1184
|
-
</>
|
|
1185
|
-
);
|
|
1186
|
-
}
|
|
1187
|
-
```
|
|
1188
|
-
|
|
1189
|
-
## Timeout Handling
|
|
1190
|
-
|
|
1191
|
-
### Request Timeouts
|
|
1192
|
-
|
|
1193
|
-
```typescript
|
|
1194
|
-
import axios from "axios";
|
|
1195
|
-
|
|
1196
|
-
const api = axios.create({
|
|
1197
|
-
timeout: 30000, // 30 seconds
|
|
1198
|
-
});
|
|
1199
|
-
|
|
1200
|
-
api.interceptors.response.use(
|
|
1201
|
-
(response) => response,
|
|
1202
|
-
(error) => {
|
|
1203
|
-
if (error.code === "ECONNABORTED") {
|
|
1204
|
-
showErrorToast("Request timed out. Please try again.");
|
|
1205
|
-
}
|
|
1206
|
-
return Promise.reject(error);
|
|
1207
|
-
},
|
|
1208
|
-
);
|
|
1209
|
-
```
|
|
1210
|
-
|
|
1211
|
-
## Error Boundaries
|
|
1212
|
-
|
|
1213
|
-
### React Error Boundary
|
|
1214
|
-
|
|
1215
|
-
```typescript
|
|
1216
|
-
import { Component, type ReactNode } from "react";
|
|
1217
|
-
|
|
1218
|
-
interface Props {
|
|
1219
|
-
children: ReactNode;
|
|
1220
|
-
fallback?: ReactNode;
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
interface State {
|
|
1224
|
-
hasError: boolean;
|
|
1225
|
-
error?: Error;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
export class ErrorBoundary extends Component<Props, State> {
|
|
1229
|
-
constructor(props: Props) {
|
|
1230
|
-
super(props);
|
|
1231
|
-
this.state = { hasError: false };
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
static getDerivedStateFromError(error: Error): State {
|
|
482
|
+
static getDerivedStateFromError(error: Error) {
|
|
1235
483
|
return { hasError: true, error };
|
|
1236
484
|
}
|
|
1237
485
|
|
|
1238
|
-
componentDidCatch(error: Error, errorInfo:
|
|
1239
|
-
logEvent("
|
|
1240
|
-
error: error.message,
|
|
1241
|
-
componentStack: errorInfo.componentStack,
|
|
1242
|
-
});
|
|
486
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
487
|
+
logEvent("ERROR_BOUNDARY", { error: error.message });
|
|
1243
488
|
}
|
|
1244
489
|
|
|
1245
490
|
render() {
|
|
1246
|
-
|
|
1247
|
-
return this.props.fallback || <ErrorFallback />;
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
return this.props.children;
|
|
491
|
+
return this.state.hasError ? <ErrorFallback /> : this.props.children;
|
|
1251
492
|
}
|
|
1252
493
|
}
|
|
1253
494
|
```
|
|
1254
495
|
|
|
1255
496
|
## Best Practices
|
|
1256
497
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
// ✅ Handle error states
|
|
1264
|
-
const { data, isError, error } = useGetData();
|
|
1265
|
-
if (isError) {
|
|
1266
|
-
return <ErrorState error={error} />;
|
|
1267
|
-
}
|
|
1268
|
-
```
|
|
1269
|
-
|
|
1270
|
-
### 2. Provide User-Friendly Messages
|
|
1271
|
-
|
|
1272
|
-
```typescript
|
|
1273
|
-
// ❌ Show technical errors to users
|
|
1274
|
-
<Alert>{error.message}</Alert>
|
|
1275
|
-
|
|
1276
|
-
// ✅ Show helpful messages
|
|
1277
|
-
<Alert>
|
|
1278
|
-
We couldn't load your shifts. Please check your connection and try again.
|
|
1279
|
-
</Alert>
|
|
1280
|
-
```
|
|
1281
|
-
|
|
1282
|
-
### 3. Log Errors for Debugging
|
|
1283
|
-
|
|
1284
|
-
```typescript
|
|
1285
|
-
// Always log errors for monitoring
|
|
1286
|
-
onError: (error) => {
|
|
1287
|
-
logEvent(APP_V2_APP_EVENTS.ERROR, {
|
|
1288
|
-
context: "shift-booking",
|
|
1289
|
-
error: error.message,
|
|
1290
|
-
});
|
|
1291
|
-
showErrorToast("Booking failed");
|
|
1292
|
-
};
|
|
1293
|
-
```
|
|
1294
|
-
|
|
1295
|
-
### 4. Provide Recovery Actions
|
|
1296
|
-
|
|
1297
|
-
```typescript
|
|
1298
|
-
// ✅ Give users a way to recover
|
|
1299
|
-
<ErrorState message="Failed to load data" onRetry={refetch} onDismiss={() => navigate("/home")} />
|
|
1300
|
-
```
|
|
1301
|
-
|
|
1302
|
-
### 5. Different Strategies for Different Errors
|
|
1303
|
-
|
|
1304
|
-
```typescript
|
|
1305
|
-
// Critical errors: Show error boundary
|
|
1306
|
-
useErrorBoundary: (error) => isCriticalError(error);
|
|
1307
|
-
|
|
1308
|
-
// Expected errors: Show inline message
|
|
1309
|
-
if (isError && error.response?.status === 404) {
|
|
1310
|
-
return <NotFoundMessage />;
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
// Transient errors: Auto-retry with backoff
|
|
1314
|
-
retry: (failureCount) => failureCount < 3;
|
|
1315
|
-
```
|
|
498
|
+
- **Always handle errors** - Check `isError` state
|
|
499
|
+
- **User-friendly messages** - Don't show technical errors
|
|
500
|
+
- **Log for debugging** - Include context
|
|
501
|
+
- **Provide recovery actions** - Retry, dismiss, navigate
|
|
502
|
+
- **Different strategies** - Error boundary vs inline vs retry
|
|
1316
503
|
|
|
1317
504
|
<!-- Source: .ruler/frontend/file-organization.md -->
|
|
1318
505
|
|
|
1319
506
|
# File Organization Standards
|
|
1320
507
|
|
|
1321
|
-
## Feature-
|
|
508
|
+
## Feature-based Structure
|
|
1322
509
|
|
|
1323
510
|
```text
|
|
1324
511
|
FeatureName/
|
|
@@ -1347,2442 +534,422 @@ FeatureName/
|
|
|
1347
534
|
└── README.md # Feature documentation (optional)
|
|
1348
535
|
```
|
|
1349
536
|
|
|
1350
|
-
##
|
|
1351
|
-
|
|
1352
|
-
### React Components
|
|
1353
|
-
|
|
1354
|
-
- **PascalCase** for all React components
|
|
1355
|
-
- Examples: `Button.tsx`, `UserProfile.tsx`, `ShiftCard.tsx`
|
|
1356
|
-
|
|
1357
|
-
### Avoid Path Stuttering
|
|
1358
|
-
|
|
1359
|
-
Don't repeat directory names in file names - the full path provides enough context:
|
|
1360
|
-
|
|
1361
|
-
❌ **Bad** - Path stuttering:
|
|
1362
|
-
|
|
1363
|
-
```text
|
|
1364
|
-
Shift/
|
|
1365
|
-
ShiftInvites/
|
|
1366
|
-
ShiftInviteCard.tsx // ❌ "Shift" repeated 3 times in path
|
|
1367
|
-
ShiftInviteList.tsx // ❌ Import: Shift/ShiftInvites/ShiftInviteCard
|
|
1368
|
-
```
|
|
1369
|
-
|
|
1370
|
-
✅ **Good** - Clean, concise:
|
|
1371
|
-
|
|
1372
|
-
```text
|
|
1373
|
-
Shift/
|
|
1374
|
-
Invites/
|
|
1375
|
-
Card.tsx // ✅ Path: Shift/Invites/Card
|
|
1376
|
-
List.tsx // ✅ Import: Shift/Invites/List
|
|
1377
|
-
```
|
|
1378
|
-
|
|
1379
|
-
**Reasoning:**
|
|
1380
|
-
|
|
1381
|
-
- The full path already provides context (`Shift/Invites/Card` is clear)
|
|
1382
|
-
- Repeating names makes imports verbose: `import { ShiftInviteCard } from 'Shift/ShiftInvites/ShiftInviteCard'`
|
|
1383
|
-
- Shorter names are easier to work with in the editor
|
|
1384
|
-
|
|
1385
|
-
### Utilities and Hooks
|
|
1386
|
-
|
|
1387
|
-
- **camelCase** for utilities, hooks, and non-component files
|
|
1388
|
-
- Examples: `formatDate.ts`, `useAuth.ts`, `calculateTotal.ts`
|
|
1389
|
-
|
|
1390
|
-
### Multi-word Non-Components
|
|
1391
|
-
|
|
1392
|
-
- **camelCase** for multi-word utility and configuration files
|
|
1393
|
-
- Examples: `userProfileUtils.ts`, `apiHelpers.ts`
|
|
1394
|
-
|
|
1395
|
-
### Test Files
|
|
1396
|
-
|
|
1397
|
-
- Co-locate with source: `Button.test.tsx` next to `Button.tsx`
|
|
1398
|
-
- Test folder: `__tests__/` for integration tests
|
|
1399
|
-
- Pattern: `*.test.ts` or `*.test.tsx`
|
|
537
|
+
## Naming Conventions
|
|
1400
538
|
|
|
1401
|
-
|
|
539
|
+
- Components: `PascalCase.tsx` (`UserProfile.tsx`)
|
|
540
|
+
- Hooks: `camelCase.ts` (`useUserProfile.ts`)
|
|
541
|
+
- Utils: `camelCase.ts` (`formatDate.ts`)
|
|
542
|
+
- Types: `camelCase.types.ts` (`user.types.ts`)
|
|
543
|
+
- Tests: `ComponentName.test.tsx`
|
|
1402
544
|
|
|
1403
|
-
|
|
1404
|
-
- `constants.ts` - Feature constants
|
|
1405
|
-
- `paths.ts` - Route path constants
|
|
545
|
+
<!-- Source: .ruler/frontend/react-patterns.md -->
|
|
1406
546
|
|
|
1407
|
-
|
|
547
|
+
# React Component Patterns
|
|
1408
548
|
|
|
1409
|
-
|
|
549
|
+
## Component Structure
|
|
1410
550
|
|
|
1411
551
|
```typescript
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
// 2. Internal packages (@clipboard-health, @src)
|
|
1418
|
-
import { Button } from "@clipboard-health/ui-components";
|
|
1419
|
-
import { CbhIcon } from "@clipboard-health/ui-components";
|
|
1420
|
-
import { formatDate } from "@src/appV2/lib/dates";
|
|
1421
|
-
import { useDefinedWorker } from "@src/appV2/Worker/useDefinedWorker";
|
|
1422
|
-
|
|
1423
|
-
// 3. Relative imports (same feature)
|
|
1424
|
-
import { useFeatureData } from "./hooks/useFeatureData";
|
|
1425
|
-
import { FeatureCard } from "./components/FeatureCard";
|
|
1426
|
-
import { FEATURE_PATHS } from "./paths";
|
|
1427
|
-
import { type FeatureData } from "./types";
|
|
1428
|
-
```
|
|
552
|
+
interface Props {
|
|
553
|
+
userId: string;
|
|
554
|
+
onUpdate?: (user: User) => void;
|
|
555
|
+
}
|
|
1429
556
|
|
|
1430
|
-
|
|
557
|
+
export function UserProfile({ userId, onUpdate }: Props) {
|
|
558
|
+
// 1. Hooks
|
|
559
|
+
const { data, isLoading, error } = useGetUser(userId);
|
|
560
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
1431
561
|
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
- Group type imports with their module: `import { type User } from './types'`
|
|
562
|
+
// 2. Derived state (avoid useMemo for inexpensive operations)
|
|
563
|
+
const displayName = formatName(data);
|
|
1435
564
|
|
|
1436
|
-
|
|
565
|
+
// 3. Event handlers
|
|
566
|
+
const handleSave = useCallback(async () => {
|
|
567
|
+
await saveUser(data);
|
|
568
|
+
onUpdate?.(data);
|
|
569
|
+
}, [data, onUpdate]);
|
|
1437
570
|
|
|
1438
|
-
|
|
571
|
+
// 4. Early returns
|
|
572
|
+
if (isLoading) return <Loading />;
|
|
573
|
+
if (error) return <Error message={error.message} />;
|
|
574
|
+
if (!data) return <NotFound />;
|
|
1439
575
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
DETAILS: `${FEATURE_FULL_PATH}/:id`,
|
|
1449
|
-
EDIT: `${FEATURE_FULL_PATH}/:id/edit`,
|
|
1450
|
-
CREATE: `${FEATURE_FULL_PATH}/create`,
|
|
1451
|
-
} as const;
|
|
576
|
+
// 5. Main render
|
|
577
|
+
return (
|
|
578
|
+
<Card>
|
|
579
|
+
<Typography>{displayName}</Typography>
|
|
580
|
+
<Button onClick={handleSave}>Save</Button>
|
|
581
|
+
</Card>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
1452
584
|
```
|
|
1453
585
|
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
```typescript
|
|
1457
|
-
import { useHistory } from "react-router-dom";
|
|
1458
|
-
import { FEATURE_PATHS } from "./paths";
|
|
1459
|
-
|
|
1460
|
-
// Navigation
|
|
1461
|
-
history.push(FEATURE_PATHS.DETAILS.replace(":id", featureId));
|
|
1462
|
-
|
|
1463
|
-
// Route definition
|
|
1464
|
-
<Route path={FEATURE_PATHS.DETAILS} component={FeatureDetailsPage} />;
|
|
1465
|
-
```
|
|
586
|
+
## Naming Conventions
|
|
1466
587
|
|
|
1467
|
-
|
|
588
|
+
- Components: `PascalCase` (`UserProfile`)
|
|
589
|
+
- Props interface: `Props` (co-located with component) or `ComponentNameProps` (exported/shared)
|
|
590
|
+
- Event handlers: `handle*` (`handleClick`, `handleSubmit`)
|
|
591
|
+
- Boolean props: `is*`, `has*`, `should*`
|
|
1468
592
|
|
|
1469
|
-
|
|
593
|
+
## Props Patterns
|
|
1470
594
|
|
|
1471
595
|
```typescript
|
|
1472
|
-
//
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
596
|
+
// Simple props
|
|
597
|
+
interface Props {
|
|
598
|
+
title: string;
|
|
599
|
+
count: number;
|
|
600
|
+
onAction: () => void;
|
|
1477
601
|
}
|
|
1478
602
|
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
// Option 2: Co-located with component for component-specific types
|
|
1482
|
-
// FeatureCard.tsx
|
|
1483
|
-
interface FeatureCardProps {
|
|
1484
|
-
feature: Feature;
|
|
1485
|
-
onSelect: (id: string) => void;
|
|
1486
|
-
}
|
|
603
|
+
// Discriminated unions for variants
|
|
604
|
+
type ButtonProps = { variant: "link"; href: string } | { variant: "button"; onClick: () => void };
|
|
1487
605
|
|
|
1488
|
-
|
|
1489
|
-
|
|
606
|
+
// Optional callbacks
|
|
607
|
+
interface Props {
|
|
608
|
+
onSuccess?: (data: Data) => void;
|
|
609
|
+
onError?: (error: Error) => void;
|
|
1490
610
|
}
|
|
1491
611
|
```
|
|
1492
612
|
|
|
1493
|
-
##
|
|
1494
|
-
|
|
1495
|
-
### Defining Constants
|
|
1496
|
-
|
|
1497
|
-
```typescript
|
|
1498
|
-
// constants.ts
|
|
1499
|
-
export const FEATURE_STATUS = {
|
|
1500
|
-
ACTIVE: "active",
|
|
1501
|
-
INACTIVE: "inactive",
|
|
1502
|
-
PENDING: "pending",
|
|
1503
|
-
} as const;
|
|
1504
|
-
|
|
1505
|
-
export type FeatureStatus = (typeof FEATURE_STATUS)[keyof typeof FEATURE_STATUS];
|
|
1506
|
-
|
|
1507
|
-
export const MAX_ITEMS = 100;
|
|
1508
|
-
export const DEFAULT_PAGE_SIZE = 20;
|
|
1509
|
-
|
|
1510
|
-
export const FEATURE_EVENTS = {
|
|
1511
|
-
VIEWED: "FEATURE_VIEWED",
|
|
1512
|
-
CREATED: "FEATURE_CREATED",
|
|
1513
|
-
UPDATED: "FEATURE_UPDATED",
|
|
1514
|
-
} as const;
|
|
1515
|
-
```
|
|
1516
|
-
|
|
1517
|
-
## Folder Depth Guidelines
|
|
1518
|
-
|
|
1519
|
-
- **Maximum 3 levels deep** for feature folders
|
|
1520
|
-
- For deeper nesting, consider splitting into separate features
|
|
1521
|
-
- Use meaningful folder names that describe the content
|
|
1522
|
-
|
|
1523
|
-
```text
|
|
1524
|
-
Good:
|
|
1525
|
-
├── Shift/
|
|
1526
|
-
│ ├── Calendar/
|
|
1527
|
-
│ │ └── ShiftCalendarCore.tsx
|
|
1528
|
-
│ └── Card/
|
|
1529
|
-
│ └── ShiftCard.tsx
|
|
1530
|
-
|
|
1531
|
-
Avoid:
|
|
1532
|
-
├── Shift/
|
|
1533
|
-
│ ├── Components/
|
|
1534
|
-
│ │ ├── Display/
|
|
1535
|
-
│ │ │ ├── Calendar/
|
|
1536
|
-
│ │ │ │ └── Core/
|
|
1537
|
-
│ │ │ │ └── ShiftCalendarCore.tsx # Too deep!
|
|
1538
|
-
```
|
|
1539
|
-
|
|
1540
|
-
## Module Exports
|
|
1541
|
-
|
|
1542
|
-
### Index Files
|
|
1543
|
-
|
|
1544
|
-
Avoid using `index.ts` files in the redesign folder. Prefer explicit imports.
|
|
1545
|
-
|
|
1546
|
-
```typescript
|
|
1547
|
-
// Avoid
|
|
1548
|
-
// index.ts
|
|
1549
|
-
export * from "./Button";
|
|
1550
|
-
export * from "./Card";
|
|
1551
|
-
|
|
1552
|
-
// Prefer explicit imports
|
|
1553
|
-
import { Button } from "@/components/Button";
|
|
1554
|
-
import { Card } from "@/components/Card";
|
|
1555
|
-
```
|
|
1556
|
-
|
|
1557
|
-
### Named Exports
|
|
1558
|
-
|
|
1559
|
-
Always use named exports (not default exports) in redesign code.
|
|
1560
|
-
|
|
1561
|
-
```typescript
|
|
1562
|
-
// Good
|
|
1563
|
-
export function Button(props: ButtonProps) {}
|
|
1564
|
-
|
|
1565
|
-
// Avoid
|
|
1566
|
-
export default function Button(props: ButtonProps) {}
|
|
1567
|
-
```
|
|
1568
|
-
|
|
1569
|
-
## API Folder Structure
|
|
1570
|
-
|
|
1571
|
-
```text
|
|
1572
|
-
api/
|
|
1573
|
-
├── useGetFeatures.ts # GET requests
|
|
1574
|
-
├── useCreateFeature.ts # POST requests
|
|
1575
|
-
├── useUpdateFeature.ts # PUT/PATCH requests
|
|
1576
|
-
├── useDeleteFeature.ts # DELETE requests
|
|
1577
|
-
└── schemas.ts # Zod schemas (optional)
|
|
1578
|
-
```
|
|
1579
|
-
|
|
1580
|
-
## Utils Folder Guidelines
|
|
1581
|
-
|
|
1582
|
-
- Keep utilities pure functions when possible
|
|
1583
|
-
- Co-locate tests with utilities
|
|
1584
|
-
- Export individual functions (not as objects)
|
|
613
|
+
## Composition Patterns
|
|
1585
614
|
|
|
1586
615
|
```typescript
|
|
1587
|
-
//
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
616
|
+
// Container/Presentational
|
|
617
|
+
export function UserListContainer() {
|
|
618
|
+
const { data, isLoading, error } = useUsers();
|
|
619
|
+
if (isLoading) return <Loading />;
|
|
620
|
+
if (error) return <Error />;
|
|
621
|
+
return <UserList users={data} />;
|
|
1591
622
|
}
|
|
1592
623
|
|
|
1593
|
-
//
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
Many projects wrap third-party UI library components to add app-specific functionality. This can be enforced via ESLint rules.
|
|
1610
|
-
|
|
1611
|
-
## Restricted MUI Imports
|
|
1612
|
-
|
|
1613
|
-
### ❌ Component Restrictions (Example)
|
|
1614
|
-
|
|
1615
|
-
Some projects restrict direct imports of certain components from `@mui/material`:
|
|
1616
|
-
|
|
1617
|
-
```typescript
|
|
1618
|
-
// ❌ Forbidden
|
|
1619
|
-
import {
|
|
1620
|
-
Avatar,
|
|
1621
|
-
Accordion,
|
|
1622
|
-
AccordionDetails,
|
|
1623
|
-
AccordionSummary,
|
|
1624
|
-
Badge,
|
|
1625
|
-
Button,
|
|
1626
|
-
Card,
|
|
1627
|
-
Chip,
|
|
1628
|
-
Dialog,
|
|
1629
|
-
DialogTitle,
|
|
1630
|
-
Divider,
|
|
1631
|
-
Drawer,
|
|
1632
|
-
FilledInput,
|
|
1633
|
-
Icon,
|
|
1634
|
-
IconButton,
|
|
1635
|
-
InputBase,
|
|
1636
|
-
List,
|
|
1637
|
-
ListItem,
|
|
1638
|
-
ListItemButton,
|
|
1639
|
-
ListItemIcon,
|
|
1640
|
-
ListItemText,
|
|
1641
|
-
Modal,
|
|
1642
|
-
OutlinedInput,
|
|
1643
|
-
Rating,
|
|
1644
|
-
Slider,
|
|
1645
|
-
TextField,
|
|
1646
|
-
Typography,
|
|
1647
|
-
Tab,
|
|
1648
|
-
Tabs,
|
|
1649
|
-
SvgIcon,
|
|
1650
|
-
} from "@mui/material";
|
|
1651
|
-
```
|
|
1652
|
-
|
|
1653
|
-
### ✅ Use Internal Wrappers
|
|
1654
|
-
|
|
1655
|
-
```typescript
|
|
1656
|
-
// ✅ Correct - Use project wrappers
|
|
1657
|
-
import { Button } from "@clipboard-health/ui-components/Button";
|
|
1658
|
-
import { IconButton } from "@clipboard-health/ui-components/IconButton";
|
|
1659
|
-
import { LoadingButton } from "@clipboard-health/ui-components/LoadingButton";
|
|
1660
|
-
```
|
|
1661
|
-
|
|
1662
|
-
### Rationale
|
|
1663
|
-
|
|
1664
|
-
1. Wrappers provide app-specific functionality and consistent behavior
|
|
1665
|
-
2. Use `sx` prop for custom styles with theme access
|
|
1666
|
-
3. Prefer app-specific dialog components over generic Modal components
|
|
1667
|
-
|
|
1668
|
-
## Icon Restrictions
|
|
1669
|
-
|
|
1670
|
-
### ❌ Third-Party Icons (Example)
|
|
1671
|
-
|
|
1672
|
-
```typescript
|
|
1673
|
-
// ❌ Avoid direct imports from icon libraries
|
|
1674
|
-
import SearchIcon from "@mui/icons-material/Search";
|
|
1675
|
-
import CloseIcon from "@mui/icons-material/Close";
|
|
1676
|
-
import AddIcon from "@mui/icons-material/Add";
|
|
1677
|
-
```
|
|
1678
|
-
|
|
1679
|
-
### ✅ Use Project Icon Component
|
|
1680
|
-
|
|
1681
|
-
```typescript
|
|
1682
|
-
// ✅ Correct - Use project's icon system
|
|
1683
|
-
import { Icon } from '@clipboard-health/ui-components';
|
|
1684
|
-
|
|
1685
|
-
<Icon type="search" size="large" />
|
|
1686
|
-
<Icon type="close" size="medium" />
|
|
1687
|
-
<Icon type="plus" size="small" />
|
|
1688
|
-
```
|
|
1689
|
-
|
|
1690
|
-
Many projects maintain their own icon system for consistency and customization.
|
|
1691
|
-
|
|
1692
|
-
## Allowed MUI Imports
|
|
1693
|
-
|
|
1694
|
-
### ✅ Safe to Import from MUI
|
|
1695
|
-
|
|
1696
|
-
These components can be imported directly:
|
|
1697
|
-
|
|
1698
|
-
```typescript
|
|
1699
|
-
import {
|
|
1700
|
-
Box,
|
|
1701
|
-
Stack,
|
|
1702
|
-
Container,
|
|
1703
|
-
Grid,
|
|
1704
|
-
Paper,
|
|
1705
|
-
Skeleton,
|
|
1706
|
-
CircularProgress,
|
|
1707
|
-
LinearProgress,
|
|
1708
|
-
BottomNavigation,
|
|
1709
|
-
BottomNavigationAction,
|
|
1710
|
-
ThemeProvider,
|
|
1711
|
-
useTheme,
|
|
1712
|
-
useMediaQuery,
|
|
1713
|
-
} from "@mui/material";
|
|
1714
|
-
```
|
|
1715
|
-
|
|
1716
|
-
Layout and utility components are generally safe.
|
|
1717
|
-
|
|
1718
|
-
## Path Aliases
|
|
1719
|
-
|
|
1720
|
-
### Use @ Prefix for Absolute Imports
|
|
1721
|
-
|
|
1722
|
-
```typescript
|
|
1723
|
-
// ✅ Use path aliases configured in tsconfig
|
|
1724
|
-
import { formatDate } from "@/lib/dates";
|
|
1725
|
-
import { useUser } from "@/features/user/hooks/useUser";
|
|
1726
|
-
import { Button } from "@clipboard-health/ui-components/Button";
|
|
1727
|
-
import { theme } from "@/theme";
|
|
1728
|
-
|
|
1729
|
-
// ❌ Avoid relative paths to distant folders
|
|
1730
|
-
import { formatDate } from "../../../lib/dates";
|
|
1731
|
-
```
|
|
1732
|
-
|
|
1733
|
-
### Relative Imports for Same Feature
|
|
1734
|
-
|
|
1735
|
-
```typescript
|
|
1736
|
-
// ✅ Relative imports within the same feature
|
|
1737
|
-
import { FeatureCard } from "./components/FeatureCard";
|
|
1738
|
-
import { useFeatureData } from "./hooks/useFeatureData";
|
|
1739
|
-
import { FEATURE_PATHS } from "./paths";
|
|
1740
|
-
import { type FeatureData } from "./types";
|
|
1741
|
-
```
|
|
1742
|
-
|
|
1743
|
-
## Import Grouping
|
|
1744
|
-
|
|
1745
|
-
### Standard Order
|
|
1746
|
-
|
|
1747
|
-
```typescript
|
|
1748
|
-
// 1. External dependencies (React, third-party libraries)
|
|
1749
|
-
import { useState, useEffect, useMemo } from "react";
|
|
1750
|
-
import { useQuery } from "@tanstack/react-query";
|
|
1751
|
-
import { parseISO, format } from "date-fns";
|
|
1752
|
-
import { z } from "zod";
|
|
1753
|
-
|
|
1754
|
-
// 2. Internal absolute imports (via path aliases)
|
|
1755
|
-
import { Button, Icon } from "@clipboard-health/ui-components";
|
|
1756
|
-
import { theme } from "@/theme";
|
|
1757
|
-
import { formatDate } from "@/lib/dates";
|
|
1758
|
-
import { useUser } from "@/features/user/hooks/useUser";
|
|
1759
|
-
import { APP_PATHS } from "@/constants/paths";
|
|
1760
|
-
|
|
1761
|
-
// 3. Relative imports (same feature/module)
|
|
1762
|
-
import { useFeatureData } from "./hooks/useFeatureData";
|
|
1763
|
-
import { FeatureCard } from "./components/FeatureCard";
|
|
1764
|
-
import { FEATURE_PATHS } from "./paths";
|
|
1765
|
-
import { type FeatureData, type FeatureOptions } from "./types";
|
|
1766
|
-
```
|
|
1767
|
-
|
|
1768
|
-
### Blank Lines Between Groups
|
|
1769
|
-
|
|
1770
|
-
- Add blank line between each group
|
|
1771
|
-
- Helps with readability and organization
|
|
1772
|
-
- ESLint/Prettier can auto-format this
|
|
1773
|
-
|
|
1774
|
-
## Type Imports
|
|
1775
|
-
|
|
1776
|
-
### Inline Type Imports
|
|
1777
|
-
|
|
1778
|
-
```typescript
|
|
1779
|
-
// ✅ Preferred: Inline type imports
|
|
1780
|
-
import { type User, type UserOptions } from "./types";
|
|
1781
|
-
import { formatUser } from "./utils";
|
|
1782
|
-
```
|
|
1783
|
-
|
|
1784
|
-
### Separate Type Imports
|
|
1785
|
-
|
|
1786
|
-
```typescript
|
|
1787
|
-
// ✅ Also acceptable
|
|
1788
|
-
import type { User, UserOptions } from "./types";
|
|
1789
|
-
import { formatUser } from "./utils";
|
|
624
|
+
// Compound components
|
|
625
|
+
<Card>
|
|
626
|
+
<Card.Header title="User" />
|
|
627
|
+
<Card.Body>Content</Card.Body>
|
|
628
|
+
<Card.Actions>
|
|
629
|
+
<Button>Save</Button>
|
|
630
|
+
</Card.Actions>
|
|
631
|
+
</Card>
|
|
632
|
+
|
|
633
|
+
// Render props
|
|
634
|
+
<DataProvider>
|
|
635
|
+
{({ data, isLoading }) => (
|
|
636
|
+
isLoading ? <Loading /> : <Display data={data} />
|
|
637
|
+
)}
|
|
638
|
+
</DataProvider>
|
|
1790
639
|
```
|
|
1791
640
|
|
|
1792
|
-
##
|
|
1793
|
-
|
|
1794
|
-
### Consider Avoiding Barrel Exports
|
|
641
|
+
## Children Patterns
|
|
1795
642
|
|
|
1796
643
|
```typescript
|
|
1797
|
-
//
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
```
|
|
644
|
+
// Typed children
|
|
645
|
+
interface Props {
|
|
646
|
+
children: ReactNode;
|
|
647
|
+
}
|
|
1802
648
|
|
|
1803
|
-
|
|
649
|
+
// Render prop pattern
|
|
650
|
+
interface Props {
|
|
651
|
+
children: (data: Data) => ReactNode;
|
|
652
|
+
}
|
|
1804
653
|
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
654
|
+
// Element restrictions
|
|
655
|
+
interface Props {
|
|
656
|
+
children: ReactElement<ButtonProps>;
|
|
657
|
+
}
|
|
1809
658
|
```
|
|
1810
659
|
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
## Dynamic Imports
|
|
1814
|
-
|
|
1815
|
-
### Code Splitting
|
|
660
|
+
## List Rendering
|
|
1816
661
|
|
|
1817
662
|
```typescript
|
|
1818
|
-
//
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
// In component
|
|
1822
|
-
<Suspense fallback={<Loading />}>
|
|
1823
|
-
<HeavyComponent />
|
|
1824
|
-
</Suspense>;
|
|
1825
|
-
```
|
|
663
|
+
// ✅ Proper keys
|
|
664
|
+
{users.map((user) => <UserCard key={user.id} user={user} />)}
|
|
1826
665
|
|
|
1827
|
-
|
|
666
|
+
// ✅ Empty states
|
|
667
|
+
{users.length === 0 ? <EmptyState /> : users.map(...)}
|
|
1828
668
|
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
```typescript
|
|
1832
|
-
import { useQuery } from "@/lib/api/hooks";
|
|
1833
|
-
import { api } from "@/lib/api/client";
|
|
669
|
+
// ❌ Never use index as key when list can change
|
|
670
|
+
{items.map((item, index) => <Item key={index} />)} // Wrong!
|
|
1834
671
|
```
|
|
1835
672
|
|
|
1836
|
-
|
|
673
|
+
## Conditional Rendering
|
|
1837
674
|
|
|
1838
675
|
```typescript
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
```
|
|
676
|
+
// Simple conditional
|
|
677
|
+
{isLoading && <Spinner />}
|
|
1842
678
|
|
|
1843
|
-
|
|
679
|
+
// If-else
|
|
680
|
+
{isError ? <Error /> : <Success />}
|
|
1844
681
|
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
import { type LocationState } from "history";
|
|
1848
|
-
```
|
|
682
|
+
// Multiple conditions
|
|
683
|
+
{isLoading ? <Loading /> : isError ? <Error /> : <Data />}
|
|
1849
684
|
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
685
|
+
// With early returns (preferred for complex logic)
|
|
686
|
+
if (isLoading) return <Loading />;
|
|
687
|
+
if (isError) return <Error />;
|
|
688
|
+
return <Data />;
|
|
1854
689
|
```
|
|
1855
690
|
|
|
1856
|
-
|
|
691
|
+
## Performance
|
|
1857
692
|
|
|
1858
693
|
```typescript
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
```
|
|
1862
|
-
|
|
1863
|
-
## ESLint Configuration
|
|
1864
|
-
|
|
1865
|
-
Import restrictions can be enforced with ESLint's `no-restricted-imports` rule:
|
|
1866
|
-
|
|
1867
|
-
```javascript
|
|
1868
|
-
module.exports = {
|
|
1869
|
-
rules: {
|
|
1870
|
-
"no-restricted-imports": [
|
|
1871
|
-
"error",
|
|
1872
|
-
{
|
|
1873
|
-
paths: [
|
|
1874
|
-
{
|
|
1875
|
-
name: "@mui/material",
|
|
1876
|
-
importNames: ["Button", "TextField", "Dialog"],
|
|
1877
|
-
message: "Use wrapper components from @/components",
|
|
1878
|
-
},
|
|
1879
|
-
],
|
|
1880
|
-
patterns: [
|
|
1881
|
-
{
|
|
1882
|
-
group: ["@mui/icons-material/*"],
|
|
1883
|
-
message: "Use project icon component instead",
|
|
1884
|
-
},
|
|
1885
|
-
],
|
|
1886
|
-
},
|
|
1887
|
-
],
|
|
1888
|
-
},
|
|
1889
|
-
};
|
|
1890
|
-
```
|
|
694
|
+
// Memoize expensive components
|
|
695
|
+
const MemoItem = memo(({ item }: Props) => <div>{item.name}</div>);
|
|
1891
696
|
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
npm run lint
|
|
1897
|
-
|
|
1898
|
-
# Auto-fix import issues
|
|
1899
|
-
npm run lint:fix
|
|
1900
|
-
```
|
|
1901
|
-
|
|
1902
|
-
## Migration Guide
|
|
1903
|
-
|
|
1904
|
-
### When You See Import Errors
|
|
1905
|
-
|
|
1906
|
-
1. **Wrapper Component Error**
|
|
1907
|
-
- Check if wrapper exists in `@/components/`
|
|
1908
|
-
- If yes, import from there
|
|
1909
|
-
- If no, discuss with team about creating wrapper
|
|
1910
|
-
|
|
1911
|
-
2. **Icon Error**
|
|
1912
|
-
- Find equivalent in project icon system
|
|
1913
|
-
- Use project's icon component
|
|
1914
|
-
- Check available icon names in documentation
|
|
1915
|
-
|
|
1916
|
-
3. **Deprecated Pattern Error**
|
|
1917
|
-
- Follow the suggested replacement pattern
|
|
1918
|
-
- Move to modern alternatives (e.g., `sx` prop instead of `styled`)
|
|
1919
|
-
|
|
1920
|
-
## Summary
|
|
1921
|
-
|
|
1922
|
-
✅ **DO**:
|
|
1923
|
-
|
|
1924
|
-
- Use project wrapper components instead of third-party components directly
|
|
1925
|
-
- Use project icon system for consistency
|
|
1926
|
-
- Use path aliases (e.g., `@/`) for absolute imports
|
|
1927
|
-
- Group imports by external, internal, relative
|
|
1928
|
-
- Consider avoiding barrel exports for better build performance
|
|
1929
|
-
|
|
1930
|
-
❌ **DON'T**:
|
|
1931
|
-
|
|
1932
|
-
- Import third-party UI components directly if wrappers exist
|
|
1933
|
-
- Import icon libraries directly if project has custom icon system
|
|
1934
|
-
- Use deprecated styling patterns (check project guidelines)
|
|
1935
|
-
- Use relative imports for distant files
|
|
1936
|
-
- Create excessive barrel exports that hurt tree-shaking
|
|
1937
|
-
|
|
1938
|
-
<!-- Source: .ruler/frontend/performance.md -->
|
|
1939
|
-
|
|
1940
|
-
# Performance Standards
|
|
1941
|
-
|
|
1942
|
-
## React Query Optimization
|
|
1943
|
-
|
|
1944
|
-
### Stale Time Configuration
|
|
1945
|
-
|
|
1946
|
-
```typescript
|
|
1947
|
-
// Set appropriate staleTime to avoid unnecessary refetch
|
|
1948
|
-
useGetQuery({
|
|
1949
|
-
url: "/api/resource",
|
|
1950
|
-
responseSchema: schema,
|
|
1951
|
-
staleTime: minutesToMilliseconds(5), // Don't refetch for 5 minutes
|
|
1952
|
-
cacheTime: minutesToMilliseconds(30), // Keep in cache for 30 minutes
|
|
1953
|
-
});
|
|
1954
|
-
```
|
|
1955
|
-
|
|
1956
|
-
### Conditional Fetching
|
|
1957
|
-
|
|
1958
|
-
```typescript
|
|
1959
|
-
// Only fetch when conditions are met
|
|
1960
|
-
const { data } = useGetQuery({
|
|
1961
|
-
url: `/api/users/${userId}`,
|
|
1962
|
-
responseSchema: userSchema,
|
|
1963
|
-
enabled: isDefined(userId) && isFeatureEnabled, // Don't fetch until ready
|
|
1964
|
-
});
|
|
1965
|
-
```
|
|
1966
|
-
|
|
1967
|
-
### Query Cancellation
|
|
1968
|
-
|
|
1969
|
-
```typescript
|
|
1970
|
-
export function usePaginatedData(params: Params) {
|
|
1971
|
-
const queryClient = useQueryClient();
|
|
1972
|
-
|
|
1973
|
-
return useInfiniteQuery({
|
|
1974
|
-
queryKey: ["data", params],
|
|
1975
|
-
queryFn: async ({ pageParam }) => {
|
|
1976
|
-
// Cancel previous in-flight requests
|
|
1977
|
-
await queryClient.cancelQueries({ queryKey: ["data"] });
|
|
1978
|
-
|
|
1979
|
-
const response = await get({
|
|
1980
|
-
url: "/api/data",
|
|
1981
|
-
queryParams: { cursor: pageParam, ...params },
|
|
1982
|
-
});
|
|
1983
|
-
return response.data;
|
|
1984
|
-
},
|
|
1985
|
-
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
1986
|
-
});
|
|
1987
|
-
}
|
|
1988
|
-
```
|
|
1989
|
-
|
|
1990
|
-
### Prefetching
|
|
1991
|
-
|
|
1992
|
-
```typescript
|
|
1993
|
-
export function usePreloadNextPage(nextUserId: string) {
|
|
1994
|
-
const queryClient = useQueryClient();
|
|
1995
|
-
|
|
1996
|
-
useEffect(() => {
|
|
1997
|
-
if (nextUserId) {
|
|
1998
|
-
// Prefetch next page in background
|
|
1999
|
-
queryClient.prefetchQuery({
|
|
2000
|
-
queryKey: ["user", nextUserId],
|
|
2001
|
-
queryFn: () => fetchUser(nextUserId),
|
|
2002
|
-
staleTime: minutesToMilliseconds(5),
|
|
2003
|
-
});
|
|
2004
|
-
}
|
|
2005
|
-
}, [nextUserId, queryClient]);
|
|
2006
|
-
}
|
|
2007
|
-
```
|
|
2008
|
-
|
|
2009
|
-
## React Optimization
|
|
2010
|
-
|
|
2011
|
-
### useMemo for Expensive Computations
|
|
2012
|
-
|
|
2013
|
-
```typescript
|
|
2014
|
-
export function DataList({ items }: { items: Item[] }) {
|
|
2015
|
-
// Memoize expensive sorting/filtering
|
|
2016
|
-
const sortedItems = useMemo(() => {
|
|
2017
|
-
return items.filter((item) => item.isActive).sort((a, b) => a.priority - b.priority);
|
|
2018
|
-
}, [items]);
|
|
2019
|
-
|
|
2020
|
-
return <List items={sortedItems} />;
|
|
2021
|
-
}
|
|
2022
|
-
```
|
|
2023
|
-
|
|
2024
|
-
### useCallback for Event Handlers
|
|
2025
|
-
|
|
2026
|
-
```typescript
|
|
2027
|
-
export function ParentComponent() {
|
|
2028
|
-
const [selected, setSelected] = useState<string>();
|
|
2029
|
-
|
|
2030
|
-
// Memoize callback to prevent child re-renders
|
|
2031
|
-
const handleSelect = useCallback((id: string) => {
|
|
2032
|
-
setSelected(id);
|
|
2033
|
-
logEvent("ITEM_SELECTED", { id });
|
|
2034
|
-
}, []); // Stable reference
|
|
2035
|
-
|
|
2036
|
-
return <ChildComponent onSelect={handleSelect} />;
|
|
2037
|
-
}
|
|
2038
|
-
```
|
|
2039
|
-
|
|
2040
|
-
### React.memo for Pure Components
|
|
2041
|
-
|
|
2042
|
-
```typescript
|
|
2043
|
-
import { memo } from "react";
|
|
2044
|
-
|
|
2045
|
-
interface ItemCardProps {
|
|
2046
|
-
item: Item;
|
|
2047
|
-
onSelect: (id: string) => void;
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
// Memoize component to prevent unnecessary re-renders
|
|
2051
|
-
export const ItemCard = memo(function ItemCard({ item, onSelect }: ItemCardProps) {
|
|
2052
|
-
return (
|
|
2053
|
-
<Card onClick={() => onSelect(item.id)}>
|
|
2054
|
-
<h3>{item.name}</h3>
|
|
2055
|
-
</Card>
|
|
2056
|
-
);
|
|
2057
|
-
});
|
|
2058
|
-
```
|
|
2059
|
-
|
|
2060
|
-
### Avoid Inline Object/Array Creation
|
|
2061
|
-
|
|
2062
|
-
```typescript
|
|
2063
|
-
// ❌ Creates new object on every render
|
|
2064
|
-
<Component style={{ padding: 8 }} />
|
|
2065
|
-
<Component items={[1, 2, 3]} />
|
|
2066
|
-
|
|
2067
|
-
// ✅ Define outside or use useMemo
|
|
2068
|
-
const style = { padding: 8 };
|
|
2069
|
-
const items = [1, 2, 3];
|
|
2070
|
-
<Component style={style} items={items} />
|
|
2071
|
-
|
|
2072
|
-
// ✅ Or use useMemo for dynamic values
|
|
2073
|
-
const style = useMemo(() => ({ padding: spacing }), [spacing]);
|
|
2074
|
-
```
|
|
2075
|
-
|
|
2076
|
-
## List Rendering
|
|
2077
|
-
|
|
2078
|
-
### Key Prop for Lists
|
|
2079
|
-
|
|
2080
|
-
```typescript
|
|
2081
|
-
// ✅ Use stable, unique keys
|
|
2082
|
-
items.map((item) => <ItemCard key={item.id} item={item} />);
|
|
2083
|
-
|
|
2084
|
-
// ❌ Don't use index as key (unless list never changes)
|
|
2085
|
-
items.map((item, index) => <ItemCard key={index} item={item} />);
|
|
2086
|
-
```
|
|
2087
|
-
|
|
2088
|
-
### Virtualization for Long Lists
|
|
2089
|
-
|
|
2090
|
-
```typescript
|
|
2091
|
-
import { FixedSizeList } from "react-window";
|
|
2092
|
-
|
|
2093
|
-
export function VirtualizedList({ items }: { items: Item[] }) {
|
|
2094
|
-
return (
|
|
2095
|
-
<FixedSizeList height={600} itemCount={items.length} itemSize={80} width="100%">
|
|
2096
|
-
{({ index, style }) => (
|
|
2097
|
-
<div style={style}>
|
|
2098
|
-
<ItemCard item={items[index]} />
|
|
2099
|
-
</div>
|
|
2100
|
-
)}
|
|
2101
|
-
</FixedSizeList>
|
|
2102
|
-
);
|
|
2103
|
-
}
|
|
2104
|
-
```
|
|
2105
|
-
|
|
2106
|
-
### Pagination Instead of Infinite Data
|
|
2107
|
-
|
|
2108
|
-
```typescript
|
|
2109
|
-
// For large datasets, use pagination
|
|
2110
|
-
export function PaginatedList() {
|
|
2111
|
-
const [page, setPage] = useState(1);
|
|
2112
|
-
const pageSize = 20;
|
|
2113
|
-
|
|
2114
|
-
const { data, isLoading } = useGetPaginatedData({
|
|
2115
|
-
page,
|
|
2116
|
-
pageSize,
|
|
2117
|
-
});
|
|
697
|
+
// Split state to avoid re-renders
|
|
698
|
+
function Parent() {
|
|
699
|
+
const [count, setCount] = useState(0);
|
|
700
|
+
const [text, setText] = useState("");
|
|
2118
701
|
|
|
702
|
+
// Only CountDisplay re-renders when count changes
|
|
2119
703
|
return (
|
|
2120
704
|
<>
|
|
2121
|
-
<
|
|
2122
|
-
<
|
|
705
|
+
<CountDisplay count={count} />
|
|
706
|
+
<TextInput value={text} onChange={setText} />
|
|
2123
707
|
</>
|
|
2124
708
|
);
|
|
2125
709
|
}
|
|
2126
710
|
```
|
|
2127
711
|
|
|
2128
|
-
##
|
|
2129
|
-
|
|
2130
|
-
### Parallel Queries
|
|
2131
|
-
|
|
2132
|
-
```typescript
|
|
2133
|
-
export function useParallelData() {
|
|
2134
|
-
// Fetch in parallel
|
|
2135
|
-
const users = useGetUsers();
|
|
2136
|
-
const shifts = useGetShifts();
|
|
2137
|
-
const workplaces = useGetWorkplaces();
|
|
2138
|
-
|
|
2139
|
-
// All queries run simultaneously
|
|
2140
|
-
const isLoading = users.isLoading || shifts.isLoading || workplaces.isLoading;
|
|
2141
|
-
|
|
2142
|
-
return {
|
|
2143
|
-
users: users.data,
|
|
2144
|
-
shifts: shifts.data,
|
|
2145
|
-
workplaces: workplaces.data,
|
|
2146
|
-
isLoading,
|
|
2147
|
-
};
|
|
2148
|
-
}
|
|
2149
|
-
```
|
|
2150
|
-
|
|
2151
|
-
### Dependent Queries
|
|
2152
|
-
|
|
2153
|
-
```typescript
|
|
2154
|
-
export function useDependentData(userId?: string) {
|
|
2155
|
-
// First query
|
|
2156
|
-
const { data: user } = useGetUser(userId, {
|
|
2157
|
-
enabled: isDefined(userId),
|
|
2158
|
-
});
|
|
2159
|
-
|
|
2160
|
-
// Second query depends on first
|
|
2161
|
-
const { data: shifts } = useGetUserShifts(user?.id, {
|
|
2162
|
-
enabled: isDefined(user?.id), // Only fetch when user is loaded
|
|
2163
|
-
});
|
|
2164
|
-
|
|
2165
|
-
return { user, shifts };
|
|
2166
|
-
}
|
|
2167
|
-
```
|
|
2168
|
-
|
|
2169
|
-
### Debouncing Search Queries
|
|
2170
|
-
|
|
2171
|
-
```typescript
|
|
2172
|
-
import { useDebouncedValue } from "@/lib/hooks";
|
|
2173
|
-
|
|
2174
|
-
export function SearchComponent() {
|
|
2175
|
-
const [searchTerm, setSearchTerm] = useState("");
|
|
2176
|
-
const debouncedSearch = useDebouncedValue(searchTerm, 300);
|
|
2177
|
-
|
|
2178
|
-
const { data } = useSearchQuery(debouncedSearch, {
|
|
2179
|
-
enabled: debouncedSearch.length > 2,
|
|
2180
|
-
});
|
|
2181
|
-
|
|
2182
|
-
return (
|
|
2183
|
-
<>
|
|
2184
|
-
<input value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
|
|
2185
|
-
<Results data={data} />
|
|
2186
|
-
</>
|
|
2187
|
-
);
|
|
2188
|
-
}
|
|
2189
|
-
```
|
|
2190
|
-
|
|
2191
|
-
## Code Splitting
|
|
2192
|
-
|
|
2193
|
-
### Route-Based Splitting
|
|
2194
|
-
|
|
2195
|
-
```typescript
|
|
2196
|
-
import { lazy, Suspense } from "react";
|
|
2197
|
-
|
|
2198
|
-
// Lazy load route components
|
|
2199
|
-
const ShiftDetailsPage = lazy(() => import("./Shift/DetailsPage"));
|
|
2200
|
-
const ProfilePage = lazy(() => import("./Profile/Page"));
|
|
2201
|
-
|
|
2202
|
-
export function Router() {
|
|
2203
|
-
return (
|
|
2204
|
-
<Suspense fallback={<LoadingScreen />}>
|
|
2205
|
-
<Routes>
|
|
2206
|
-
<Route path="/shifts/:id" element={<ShiftDetailsPage />} />
|
|
2207
|
-
<Route path="/profile" element={<ProfilePage />} />
|
|
2208
|
-
</Routes>
|
|
2209
|
-
</Suspense>
|
|
2210
|
-
);
|
|
2211
|
-
}
|
|
2212
|
-
```
|
|
2213
|
-
|
|
2214
|
-
### Component-Based Splitting
|
|
2215
|
-
|
|
2216
|
-
```typescript
|
|
2217
|
-
// Split large components that aren't immediately needed
|
|
2218
|
-
const HeavyChart = lazy(() => import("./HeavyChart"));
|
|
2219
|
-
|
|
2220
|
-
export function Dashboard() {
|
|
2221
|
-
const [showChart, setShowChart] = useState(false);
|
|
2222
|
-
|
|
2223
|
-
return (
|
|
2224
|
-
<div>
|
|
2225
|
-
<Summary />
|
|
2226
|
-
{showChart && (
|
|
2227
|
-
<Suspense fallback={<ChartSkeleton />}>
|
|
2228
|
-
<HeavyChart />
|
|
2229
|
-
</Suspense>
|
|
2230
|
-
)}
|
|
2231
|
-
</div>
|
|
2232
|
-
);
|
|
2233
|
-
}
|
|
2234
|
-
```
|
|
2235
|
-
|
|
2236
|
-
## Image Optimization
|
|
2237
|
-
|
|
2238
|
-
### Lazy Loading Images
|
|
2239
|
-
|
|
2240
|
-
```typescript
|
|
2241
|
-
<img
|
|
2242
|
-
src={imageUrl}
|
|
2243
|
-
alt={alt}
|
|
2244
|
-
loading="lazy" // Native lazy loading
|
|
2245
|
-
/>
|
|
2246
|
-
```
|
|
2247
|
-
|
|
2248
|
-
### Responsive Images
|
|
2249
|
-
|
|
2250
|
-
```typescript
|
|
2251
|
-
<img
|
|
2252
|
-
srcSet={`
|
|
2253
|
-
${image_small} 480w,
|
|
2254
|
-
${image_medium} 800w,
|
|
2255
|
-
${image_large} 1200w
|
|
2256
|
-
`}
|
|
2257
|
-
sizes="(max-width: 480px) 480px, (max-width: 800px) 800px, 1200px"
|
|
2258
|
-
src={image_medium}
|
|
2259
|
-
alt={alt}
|
|
2260
|
-
/>
|
|
2261
|
-
```
|
|
2262
|
-
|
|
2263
|
-
## State Management
|
|
2264
|
-
|
|
2265
|
-
### Avoid Prop Drilling
|
|
2266
|
-
|
|
2267
|
-
```typescript
|
|
2268
|
-
// ❌ Prop drilling
|
|
2269
|
-
<Parent>
|
|
2270
|
-
<Child data={data}>
|
|
2271
|
-
<GrandChild data={data}>
|
|
2272
|
-
<GreatGrandChild data={data} />
|
|
2273
|
-
</GrandChild>
|
|
2274
|
-
</Child>
|
|
2275
|
-
</Parent>;
|
|
2276
|
-
|
|
2277
|
-
// ✅ Use context for deeply nested data
|
|
2278
|
-
const DataContext = createContext<Data | undefined>(undefined);
|
|
2279
|
-
|
|
2280
|
-
<DataContext.Provider value={data}>
|
|
2281
|
-
<Parent>
|
|
2282
|
-
<Child>
|
|
2283
|
-
<GrandChild>
|
|
2284
|
-
<GreatGrandChild />
|
|
2285
|
-
</GrandChild>
|
|
2286
|
-
</Child>
|
|
2287
|
-
</Parent>
|
|
2288
|
-
</DataContext.Provider>;
|
|
2289
|
-
```
|
|
2290
|
-
|
|
2291
|
-
### Local State Over Global State
|
|
2292
|
-
|
|
2293
|
-
```typescript
|
|
2294
|
-
// ✅ Keep state as local as possible
|
|
2295
|
-
export function FormComponent() {
|
|
2296
|
-
const [formData, setFormData] = useState({}); // Local state
|
|
2297
|
-
// Only lift state up when needed by multiple components
|
|
2298
|
-
}
|
|
2299
|
-
```
|
|
2300
|
-
|
|
2301
|
-
## Bundle Size Optimization
|
|
2302
|
-
|
|
2303
|
-
### Tree Shaking
|
|
2304
|
-
|
|
2305
|
-
```typescript
|
|
2306
|
-
// ✅ Import only what you need
|
|
2307
|
-
import { format } from "date-fns";
|
|
2308
|
-
|
|
2309
|
-
// ❌ Imports entire library
|
|
2310
|
-
import * as dateFns from "date-fns";
|
|
2311
|
-
```
|
|
2312
|
-
|
|
2313
|
-
### Avoid Large Dependencies
|
|
2314
|
-
|
|
2315
|
-
```typescript
|
|
2316
|
-
// Check bundle size before adding dependencies
|
|
2317
|
-
// Use lighter alternatives when possible
|
|
2318
|
-
|
|
2319
|
-
// ❌ Heavy library for simple task
|
|
2320
|
-
import moment from "moment";
|
|
2321
|
-
|
|
2322
|
-
// ✅ Lighter alternative
|
|
2323
|
-
import { format } from "date-fns";
|
|
2324
|
-
```
|
|
2325
|
-
|
|
2326
|
-
## Monitoring Performance
|
|
2327
|
-
|
|
2328
|
-
### React DevTools Profiler
|
|
2329
|
-
|
|
2330
|
-
```typescript
|
|
2331
|
-
// Wrap components to profile
|
|
2332
|
-
<Profiler id="ShiftList" onRender={onRenderCallback}>
|
|
2333
|
-
<ShiftList />
|
|
2334
|
-
</Profiler>
|
|
2335
|
-
```
|
|
2336
|
-
|
|
2337
|
-
### Web Vitals
|
|
2338
|
-
|
|
2339
|
-
```typescript
|
|
2340
|
-
import { getCLS, getFID, getFCP, getLCP, getTTFB } from "web-vitals";
|
|
2341
|
-
|
|
2342
|
-
// Track Core Web Vitals
|
|
2343
|
-
getCLS(console.log);
|
|
2344
|
-
getFID(console.log);
|
|
2345
|
-
getFCP(console.log);
|
|
2346
|
-
getLCP(console.log);
|
|
2347
|
-
getTTFB(console.log);
|
|
2348
|
-
```
|
|
2349
|
-
|
|
2350
|
-
## Best Practices Summary
|
|
2351
|
-
|
|
2352
|
-
### ✅ DO
|
|
2353
|
-
|
|
2354
|
-
- Set appropriate `staleTime` and `cacheTime` for queries
|
|
2355
|
-
- Use `useMemo` for expensive computations
|
|
2356
|
-
- Use `useCallback` for callbacks passed to children
|
|
2357
|
-
- Use React.memo for pure components
|
|
2358
|
-
- Virtualize long lists
|
|
2359
|
-
- Lazy load routes and heavy components
|
|
2360
|
-
- Use pagination for large datasets
|
|
2361
|
-
- Debounce search inputs
|
|
2362
|
-
- Cancel queries when component unmounts
|
|
2363
|
-
- Prefetch data when predictable
|
|
2364
|
-
|
|
2365
|
-
### ❌ DON'T
|
|
2366
|
-
|
|
2367
|
-
- Fetch data unnecessarily
|
|
2368
|
-
- Create objects/arrays inline in props
|
|
2369
|
-
- Use index as key for dynamic lists
|
|
2370
|
-
- Render all items in very long lists
|
|
2371
|
-
- Import entire libraries when only need parts
|
|
2372
|
-
- Keep all state global
|
|
2373
|
-
- Ignore performance warnings in console
|
|
2374
|
-
- Skip memoization for expensive operations
|
|
2375
|
-
|
|
2376
|
-
<!-- Source: .ruler/frontend/react-patterns.md -->
|
|
2377
|
-
|
|
2378
|
-
# React Component Patterns
|
|
2379
|
-
|
|
2380
|
-
## Core Principles
|
|
2381
|
-
|
|
2382
|
-
- **Storybook is the single source of truth** for UI components, not Figma
|
|
2383
|
-
- **Named exports only** (prefer named exports over default exports)
|
|
2384
|
-
- **Explicit types** for all props and return values
|
|
2385
|
-
- **Handle all states**: loading, error, empty, success
|
|
2386
|
-
- **Composition over configuration** - prefer children over complex props
|
|
2387
|
-
|
|
2388
|
-
## Component Structure
|
|
2389
|
-
|
|
2390
|
-
Follow this consistent structure for all components:
|
|
2391
|
-
|
|
2392
|
-
```typescript
|
|
2393
|
-
// 1. Imports (grouped with blank lines between groups)
|
|
2394
|
-
import { useState, useMemo, useCallback } from "react";
|
|
2395
|
-
import { Box, Typography } from "@mui/material";
|
|
2396
|
-
|
|
2397
|
-
import { useGetUser } from "@/api/hooks/useGetUser";
|
|
2398
|
-
import { formatDate } from "@/utils/date";
|
|
2399
|
-
|
|
2400
|
-
import { ChildComponent } from "./ChildComponent";
|
|
2401
|
-
|
|
2402
|
-
// 2. Types (interfaces for props, types for unions)
|
|
2403
|
-
interface UserCardProps {
|
|
2404
|
-
userId: string;
|
|
2405
|
-
onAction: (action: string) => void;
|
|
2406
|
-
isHighlighted?: boolean;
|
|
2407
|
-
}
|
|
2408
|
-
|
|
2409
|
-
// 3. Component definition
|
|
2410
|
-
export function UserCard({ userId, onAction, isHighlighted = false }: UserCardProps) {
|
|
2411
|
-
// A. React Query hooks first
|
|
2412
|
-
const { data: user, isLoading, isError } = useGetUser(userId);
|
|
2413
|
-
|
|
2414
|
-
// B. Local state
|
|
2415
|
-
const [isExpanded, setIsExpanded] = useState(false);
|
|
2416
|
-
|
|
2417
|
-
// C. Derived values (with useMemo for expensive computations)
|
|
2418
|
-
const displayName = useMemo(
|
|
2419
|
-
() => (user ? `${user.firstName} ${user.lastName}` : "Unknown"),
|
|
2420
|
-
[user]
|
|
2421
|
-
);
|
|
2422
|
-
|
|
2423
|
-
// D. Event handlers (with useCallback when passed to children)
|
|
2424
|
-
const handleToggle = useCallback(() => {
|
|
2425
|
-
setIsExpanded((prev) => !prev);
|
|
2426
|
-
onAction("toggle");
|
|
2427
|
-
}, [onAction]);
|
|
2428
|
-
|
|
2429
|
-
// E. Early returns for loading/error states
|
|
2430
|
-
if (isLoading) return <LoadingState />;
|
|
2431
|
-
if (isError) return <ErrorState message="Failed to load user" />;
|
|
2432
|
-
if (!user) return <EmptyState message="User not found" />;
|
|
2433
|
-
|
|
2434
|
-
// F. Render
|
|
2435
|
-
return (
|
|
2436
|
-
<Box sx={{ padding: 2, backgroundColor: isHighlighted ? "primary.light" : "background.paper" }}>
|
|
2437
|
-
<Typography variant="h6">{displayName}</Typography>
|
|
2438
|
-
<ChildComponent onClick={handleToggle} />
|
|
2439
|
-
</Box>
|
|
2440
|
-
);
|
|
2441
|
-
}
|
|
2442
|
-
```
|
|
2443
|
-
|
|
2444
|
-
## Why This Structure?
|
|
2445
|
-
|
|
2446
|
-
1. **Imports grouped** - Easy to see dependencies
|
|
2447
|
-
2. **Types first** - Documents component API
|
|
2448
|
-
3. **Hooks at top** - React rules of hooks
|
|
2449
|
-
4. **Derived values next** - Shows data flow
|
|
2450
|
-
5. **Handlers together** - Easy to find event logic
|
|
2451
|
-
6. **Early returns** - Fail fast, reduce nesting
|
|
2452
|
-
7. **Render last** - Main component logic
|
|
2453
|
-
|
|
2454
|
-
## Component Naming
|
|
2455
|
-
|
|
2456
|
-
- **PascalCase** for components: `UserProfile`, `DataCard`
|
|
2457
|
-
- **camelCase** for hooks and utilities: `useUserData`, `formatDate`
|
|
2458
|
-
- Prefer named exports over default exports for better refactoring support
|
|
2459
|
-
|
|
2460
|
-
## Props
|
|
2461
|
-
|
|
2462
|
-
- Always define explicit prop types
|
|
2463
|
-
- Use destructuring with types
|
|
2464
|
-
- Avoid prop spreading unless wrapping a component
|
|
2465
|
-
|
|
2466
|
-
```typescript
|
|
2467
|
-
// Good
|
|
2468
|
-
interface ButtonProps {
|
|
2469
|
-
onClick: () => void;
|
|
2470
|
-
label: string;
|
|
2471
|
-
disabled?: boolean;
|
|
2472
|
-
}
|
|
2473
|
-
|
|
2474
|
-
export function Button({ onClick, label, disabled = false }: ButtonProps) {
|
|
2475
|
-
return (
|
|
2476
|
-
<button onClick={onClick} disabled={disabled}>
|
|
2477
|
-
{label}
|
|
2478
|
-
</button>
|
|
2479
|
-
);
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
// Acceptable for wrappers
|
|
2483
|
-
export function CustomButton(props: ButtonProps) {
|
|
2484
|
-
return <ButtonBase {...props} LinkComponent={InternalLink} />;
|
|
2485
|
-
}
|
|
2486
|
-
```
|
|
2487
|
-
|
|
2488
|
-
## Wrappers
|
|
2489
|
-
|
|
2490
|
-
- Wrap third-party components to add app-specific functionality
|
|
2491
|
-
- Example: Wrapping a third-party Button with custom link handling
|
|
2492
|
-
|
|
2493
|
-
```typescript
|
|
2494
|
-
import {
|
|
2495
|
-
Button as ButtonBase,
|
|
2496
|
-
type ButtonProps as ButtonPropsBase,
|
|
2497
|
-
} from "@some-ui-library/components";
|
|
2498
|
-
import { type LocationState } from "history";
|
|
2499
|
-
|
|
2500
|
-
import { InternalLink } from "./InternalLink";
|
|
2501
|
-
|
|
2502
|
-
interface ButtonProps extends Omit<ButtonPropsBase, "LinkComponent"> {
|
|
2503
|
-
locationState?: LocationState;
|
|
2504
|
-
}
|
|
2505
|
-
|
|
2506
|
-
export function Button(props: ButtonProps) {
|
|
2507
|
-
return <ButtonBase {...props} LinkComponent={InternalLink} />;
|
|
2508
|
-
}
|
|
2509
|
-
```
|
|
2510
|
-
|
|
2511
|
-
## State Management
|
|
2512
|
-
|
|
2513
|
-
- Use `useState` for local component state
|
|
2514
|
-
- Use `useMemo` for expensive computations
|
|
2515
|
-
- Use `useCallback` for functions passed to children
|
|
2516
|
-
- Lift state up when needed by multiple components
|
|
2517
|
-
|
|
2518
|
-
## Conditional Rendering
|
|
2519
|
-
|
|
2520
|
-
```typescript
|
|
2521
|
-
// Early returns for loading/error states
|
|
2522
|
-
if (isLoading) return <LoadingState />;
|
|
2523
|
-
if (isError) return <ErrorState />;
|
|
2524
|
-
|
|
2525
|
-
// Ternary for simple conditions
|
|
2526
|
-
return isActive ? <ActiveView /> : <InactiveView />;
|
|
2527
|
-
|
|
2528
|
-
// && for conditional rendering
|
|
2529
|
-
return <div>{hasData && <DataDisplay data={data} />}</div>;
|
|
2530
|
-
```
|
|
2531
|
-
|
|
2532
|
-
## Component Composition
|
|
2533
|
-
|
|
2534
|
-
Prefer composition over complex props. Build small, focused components that compose together.
|
|
2535
|
-
|
|
2536
|
-
```typescript
|
|
2537
|
-
// ✅ Good - Composition pattern
|
|
2538
|
-
interface CardProps {
|
|
2539
|
-
children: ReactNode;
|
|
2540
|
-
}
|
|
2541
|
-
|
|
2542
|
-
export function Card({ children }: CardProps) {
|
|
2543
|
-
return <Box sx={{ padding: 3, borderRadius: 2, boxShadow: 1 }}>{children}</Box>;
|
|
2544
|
-
}
|
|
2545
|
-
|
|
2546
|
-
export function CardHeader({ children }: { children: ReactNode }) {
|
|
2547
|
-
return (
|
|
2548
|
-
<Box sx={{ marginBottom: 2 }}>
|
|
2549
|
-
<Typography variant="h6">{children}</Typography>
|
|
2550
|
-
</Box>
|
|
2551
|
-
);
|
|
2552
|
-
}
|
|
2553
|
-
|
|
2554
|
-
export function CardContent({ children }: { children: ReactNode }) {
|
|
2555
|
-
return <Box>{children}</Box>;
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
// Usage - Flexible and composable
|
|
2559
|
-
<Card>
|
|
2560
|
-
<CardHeader>User Profile</CardHeader>
|
|
2561
|
-
<CardContent>
|
|
2562
|
-
<UserDetails user={user} />
|
|
2563
|
-
<UserActions onEdit={handleEdit} />
|
|
2564
|
-
</CardContent>
|
|
2565
|
-
</Card>;
|
|
2566
|
-
|
|
2567
|
-
// ❌ Bad - Complex props that limit flexibility
|
|
2568
|
-
interface ComplexCardProps {
|
|
2569
|
-
title: string;
|
|
2570
|
-
subtitle?: string;
|
|
2571
|
-
actions?: ReactNode;
|
|
2572
|
-
content: ReactNode;
|
|
2573
|
-
variant?: "default" | "highlighted" | "error";
|
|
2574
|
-
showBorder?: boolean;
|
|
2575
|
-
elevation?: number;
|
|
2576
|
-
}
|
|
2577
|
-
|
|
2578
|
-
export function ComplexCard(props: ComplexCardProps) {
|
|
2579
|
-
// Too many props, hard to extend
|
|
2580
|
-
// ...
|
|
2581
|
-
}
|
|
2582
|
-
```
|
|
2583
|
-
|
|
2584
|
-
## Children Patterns
|
|
2585
|
-
|
|
2586
|
-
### Render Props Pattern
|
|
2587
|
-
|
|
2588
|
-
Use when child components need access to parent state:
|
|
2589
|
-
|
|
2590
|
-
```typescript
|
|
2591
|
-
interface DataProviderProps {
|
|
2592
|
-
children: (data: Data, isLoading: boolean) => ReactNode;
|
|
2593
|
-
}
|
|
2594
|
-
|
|
2595
|
-
export function DataProvider({ children }: DataProviderProps) {
|
|
2596
|
-
const { data, isLoading } = useGetData();
|
|
2597
|
-
|
|
2598
|
-
return <>{children(data, isLoading)}</>;
|
|
2599
|
-
}
|
|
2600
|
-
|
|
2601
|
-
// Usage
|
|
2602
|
-
<DataProvider>
|
|
2603
|
-
{(data, isLoading) => (isLoading ? <Loading /> : <DataDisplay data={data} />)}
|
|
2604
|
-
</DataProvider>;
|
|
2605
|
-
```
|
|
2606
|
-
|
|
2607
|
-
### Compound Components Pattern
|
|
2608
|
-
|
|
2609
|
-
For components that work together:
|
|
2610
|
-
|
|
2611
|
-
```typescript
|
|
2612
|
-
interface TabsContextValue {
|
|
2613
|
-
activeTab: string;
|
|
2614
|
-
setActiveTab: (tab: string) => void;
|
|
2615
|
-
}
|
|
2616
|
-
|
|
2617
|
-
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
|
|
2618
|
-
|
|
2619
|
-
export function Tabs({ children, defaultTab }: { children: ReactNode; defaultTab: string }) {
|
|
2620
|
-
const [activeTab, setActiveTab] = useState(defaultTab);
|
|
2621
|
-
|
|
2622
|
-
return (
|
|
2623
|
-
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
|
2624
|
-
<Box>{children}</Box>
|
|
2625
|
-
</TabsContext.Provider>
|
|
2626
|
-
);
|
|
2627
|
-
}
|
|
2628
|
-
|
|
2629
|
-
export function TabList({ children }: { children: ReactNode }) {
|
|
2630
|
-
return <Box sx={{ display: "flex", gap: 1 }}>{children}</Box>;
|
|
2631
|
-
}
|
|
2632
|
-
|
|
2633
|
-
export function Tab({ value, children }: { value: string; children: ReactNode }) {
|
|
2634
|
-
const context = useContext(TabsContext);
|
|
2635
|
-
if (!context) throw new Error("Tab must be used within Tabs");
|
|
2636
|
-
|
|
2637
|
-
const { activeTab, setActiveTab } = context;
|
|
2638
|
-
const isActive = activeTab === value;
|
|
2639
|
-
|
|
2640
|
-
return (
|
|
2641
|
-
<Button onClick={() => setActiveTab(value)} variant={isActive ? "contained" : "text"}>
|
|
2642
|
-
{children}
|
|
2643
|
-
</Button>
|
|
2644
|
-
);
|
|
2645
|
-
}
|
|
2646
|
-
|
|
2647
|
-
export function TabPanel({ value, children }: { value: string; children: ReactNode }) {
|
|
2648
|
-
const context = useContext(TabsContext);
|
|
2649
|
-
if (!context) throw new Error("TabPanel must be used within Tabs");
|
|
2650
|
-
|
|
2651
|
-
if (context.activeTab !== value) return null;
|
|
2652
|
-
return <Box sx={{ padding: 2 }}>{children}</Box>;
|
|
2653
|
-
}
|
|
2654
|
-
|
|
2655
|
-
// Usage - Intuitive API
|
|
2656
|
-
<Tabs defaultTab="profile">
|
|
2657
|
-
<TabList>
|
|
2658
|
-
<Tab value="profile">Profile</Tab>
|
|
2659
|
-
<Tab value="settings">Settings</Tab>
|
|
2660
|
-
</TabList>
|
|
2661
|
-
<TabPanel value="profile">
|
|
2662
|
-
<ProfileContent />
|
|
2663
|
-
</TabPanel>
|
|
2664
|
-
<TabPanel value="settings">
|
|
2665
|
-
<SettingsContent />
|
|
2666
|
-
</TabPanel>
|
|
2667
|
-
</Tabs>;
|
|
2668
|
-
```
|
|
2669
|
-
|
|
2670
|
-
## Error Boundaries
|
|
2671
|
-
|
|
2672
|
-
Use error boundaries for graceful error handling:
|
|
2673
|
-
|
|
2674
|
-
```typescript
|
|
2675
|
-
interface ErrorBoundaryProps {
|
|
2676
|
-
children: ReactNode;
|
|
2677
|
-
fallback?: ReactNode;
|
|
2678
|
-
}
|
|
2679
|
-
|
|
2680
|
-
interface ErrorBoundaryState {
|
|
2681
|
-
hasError: boolean;
|
|
2682
|
-
error?: Error;
|
|
2683
|
-
}
|
|
2684
|
-
|
|
2685
|
-
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
2686
|
-
constructor(props: ErrorBoundaryProps) {
|
|
2687
|
-
super(props);
|
|
2688
|
-
this.state = { hasError: false };
|
|
2689
|
-
}
|
|
2690
|
-
|
|
2691
|
-
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
2692
|
-
return { hasError: true, error };
|
|
2693
|
-
}
|
|
2694
|
-
|
|
2695
|
-
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
2696
|
-
console.error("Error caught by boundary:", error, errorInfo);
|
|
2697
|
-
// Log to error tracking service
|
|
2698
|
-
}
|
|
2699
|
-
|
|
2700
|
-
render() {
|
|
2701
|
-
if (this.state.hasError) {
|
|
2702
|
-
return this.props.fallback || <ErrorFallback error={this.state.error} />;
|
|
2703
|
-
}
|
|
2704
|
-
|
|
2705
|
-
return this.props.children;
|
|
2706
|
-
}
|
|
2707
|
-
}
|
|
2708
|
-
|
|
2709
|
-
// Usage
|
|
2710
|
-
<ErrorBoundary fallback={<ErrorPage />}>
|
|
2711
|
-
<App />
|
|
2712
|
-
</ErrorBoundary>;
|
|
2713
|
-
```
|
|
2714
|
-
|
|
2715
|
-
## Handling Lists
|
|
2716
|
-
|
|
2717
|
-
### Key Props
|
|
2718
|
-
|
|
2719
|
-
Always use stable, unique keys (never index):
|
|
2720
|
-
|
|
2721
|
-
```typescript
|
|
2722
|
-
// ✅ Good - Unique, stable ID
|
|
2723
|
-
{
|
|
2724
|
-
users.map((user) => <UserCard key={user.id} user={user} />);
|
|
2725
|
-
}
|
|
2726
|
-
|
|
2727
|
-
// ❌ Bad - Index as key (causes bugs when list changes)
|
|
2728
|
-
{
|
|
2729
|
-
users.map((user, index) => <UserCard key={index} user={user} />);
|
|
2730
|
-
}
|
|
2731
|
-
|
|
2732
|
-
// ✅ Acceptable - Composite key when no ID available
|
|
2733
|
-
{
|
|
2734
|
-
items.map((item) => <ItemCard key={`${item.type}-${item.name}`} item={item} />);
|
|
2735
|
-
}
|
|
2736
|
-
```
|
|
2737
|
-
|
|
2738
|
-
### Empty States
|
|
2739
|
-
|
|
2740
|
-
Always handle empty lists:
|
|
2741
|
-
|
|
2742
|
-
```typescript
|
|
2743
|
-
export function UserList({ users }: { users: User[] }) {
|
|
2744
|
-
if (users.length === 0) {
|
|
2745
|
-
return (
|
|
2746
|
-
<EmptyState
|
|
2747
|
-
icon={<PersonIcon />}
|
|
2748
|
-
title="No users found"
|
|
2749
|
-
description="Try adjusting your search criteria"
|
|
2750
|
-
/>
|
|
2751
|
-
);
|
|
2752
|
-
}
|
|
2753
|
-
|
|
2754
|
-
return (
|
|
2755
|
-
<Box>
|
|
2756
|
-
{users.map((user) => (
|
|
2757
|
-
<UserCard key={user.id} user={user} />
|
|
2758
|
-
))}
|
|
2759
|
-
</Box>
|
|
2760
|
-
);
|
|
2761
|
-
}
|
|
2762
|
-
```
|
|
2763
|
-
|
|
2764
|
-
## Conditional Rendering Best Practices
|
|
2765
|
-
|
|
2766
|
-
```typescript
|
|
2767
|
-
export function ItemCard({ item }: { item: Item }) {
|
|
2768
|
-
// ✅ Good - Early returns for invalid states
|
|
2769
|
-
if (!item) return null;
|
|
2770
|
-
if (item.isDeleted) return null;
|
|
2771
|
-
|
|
2772
|
-
// ✅ Good - Ternary for simple either/or
|
|
2773
|
-
const statusColor = item.isUrgent ? "error" : "default";
|
|
2774
|
-
|
|
2775
|
-
return (
|
|
2776
|
-
<Box>
|
|
2777
|
-
<Typography color={statusColor}>{item.title}</Typography>
|
|
2778
|
-
|
|
2779
|
-
{/* ✅ Good - && for optional elements */}
|
|
2780
|
-
{item.isFeatured && <FeaturedBadge />}
|
|
2781
|
-
|
|
2782
|
-
{/* ✅ Good - Ternary for alternate content */}
|
|
2783
|
-
{item.isAvailable ? <AvailableStatus /> : <UnavailableStatus />}
|
|
2784
|
-
|
|
2785
|
-
{/* ❌ Bad - Nested ternaries (hard to read) */}
|
|
2786
|
-
{item.status === "urgent" ? (
|
|
2787
|
-
<UrgentBadge />
|
|
2788
|
-
) : item.status === "normal" ? (
|
|
2789
|
-
<NormalBadge />
|
|
2790
|
-
) : (
|
|
2791
|
-
<DefaultBadge />
|
|
2792
|
-
)}
|
|
2793
|
-
|
|
2794
|
-
{/* ✅ Good - Extract to variable or switch */}
|
|
2795
|
-
<StatusBadge status={item.status} />
|
|
2796
|
-
</Box>
|
|
2797
|
-
);
|
|
2798
|
-
}
|
|
2799
|
-
```
|
|
2800
|
-
|
|
2801
|
-
## Performance Optimization
|
|
2802
|
-
|
|
2803
|
-
### When to Use `useMemo`
|
|
2804
|
-
|
|
2805
|
-
```typescript
|
|
2806
|
-
// ✅ Do - Expensive computation
|
|
2807
|
-
const sortedUsers = useMemo(() => [...users].sort((a, b) => a.name.localeCompare(b.name)), [users]);
|
|
2808
|
-
|
|
2809
|
-
// ❌ Don't - Simple operations (premature optimization)
|
|
2810
|
-
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
|
|
2811
|
-
```
|
|
2812
|
-
|
|
2813
|
-
### When to Use `useCallback`
|
|
2814
|
-
|
|
2815
|
-
```typescript
|
|
2816
|
-
// ✅ Do - Function passed to memoized child
|
|
2817
|
-
const MemoizedChild = React.memo(ChildComponent);
|
|
2818
|
-
|
|
2819
|
-
function Parent() {
|
|
2820
|
-
const handleClick = useCallback(() => {
|
|
2821
|
-
console.log("clicked");
|
|
2822
|
-
}, []);
|
|
2823
|
-
|
|
2824
|
-
return <MemoizedChild onClick={handleClick} />;
|
|
2825
|
-
}
|
|
2826
|
-
|
|
2827
|
-
// ❌ Don't - Function not passed to children
|
|
2828
|
-
const handleSubmit = useCallback(() => {
|
|
2829
|
-
// No child components use this
|
|
2830
|
-
}, []);
|
|
2831
|
-
```
|
|
2832
|
-
|
|
2833
|
-
<!-- Source: .ruler/frontend/react.md -->
|
|
2834
|
-
|
|
2835
|
-
# React
|
|
2836
|
-
|
|
2837
|
-
- Destructure props in function body rather than in function signature
|
|
2838
|
-
- Prefer inline JSX rather than extracting variables and functions as variables outside of JSX
|
|
2839
|
-
- Use useModalState for any showing/hiding functionality like dialogs
|
|
2840
|
-
- Utilize custom hooks to encapsulate and reuse stateful logic
|
|
2841
|
-
- When performing data-fetching in a custom hook, always use Zod to define any request and response schemas
|
|
2842
|
-
- Use react-hook-form for all form UIs and use zod resolver for form schema validation
|
|
2843
|
-
- Use date-fns for any Date based operations like formatting
|
|
2844
|
-
|
|
2845
|
-
<!-- Source: .ruler/frontend/styling.md -->
|
|
2846
|
-
|
|
2847
|
-
# Styling Standards
|
|
2848
|
-
|
|
2849
|
-
## Technology Stack
|
|
2850
|
-
|
|
2851
|
-
- **Material UI (MUI)** with custom theme
|
|
2852
|
-
- **sx prop** for custom styles (NOT `styled()`)
|
|
2853
|
-
- Custom component wrappers for consistent UI
|
|
2854
|
-
- **Storybook** as single source of truth for UI components
|
|
2855
|
-
|
|
2856
|
-
## Core Principles
|
|
2857
|
-
|
|
2858
|
-
1. **Always use `sx` prop** - Never CSS/SCSS/SASS files
|
|
2859
|
-
2. **Use theme tokens** - Never hardcode colors, spacing, or sizes
|
|
2860
|
-
3. **Leverage meaningful tokens** - Use semantic names like `theme.palette.text.primary`, not `common.white`
|
|
2861
|
-
4. **Type-safe theme access** - Use `sx={(theme) => ({...})}`, not string paths like `"text.secondary"`
|
|
2862
|
-
5. **Follow spacing system** - Use indices 1-12 (4px-64px)
|
|
2863
|
-
6. **Storybook is source of truth** - Check Storybook before Figma
|
|
2864
|
-
|
|
2865
|
-
## Restricted Patterns
|
|
2866
|
-
|
|
2867
|
-
### ❌ DO NOT USE
|
|
2868
|
-
|
|
2869
|
-
- `styled()` from MUI (deprecated in our codebase)
|
|
2870
|
-
- `makeStyles()` from MUI (deprecated)
|
|
2871
|
-
- CSS/SCSS/SASS files
|
|
2872
|
-
- Direct MUI icons from `@mui/icons-material`
|
|
2873
|
-
- Direct MUI components without wrappers (except layout primitives; see list below)
|
|
2874
|
-
- Inline styles via `style` prop (use `sx` instead)
|
|
2875
|
-
- String paths for theme tokens (not type-safe)
|
|
2876
|
-
|
|
2877
|
-
### Rationale
|
|
2878
|
-
|
|
2879
|
-
1. Component wrappers provide consistent behavior and app-specific functionality
|
|
2880
|
-
2. `sx` prop provides type-safe access to theme tokens
|
|
2881
|
-
3. Project-specific dialog components ensure consistent UX patterns
|
|
2882
|
-
|
|
2883
|
-
## Storybook as Source of Truth
|
|
2884
|
-
|
|
2885
|
-
**Important:** Storybook reflects what's actually implemented, not Figma designs.
|
|
2886
|
-
|
|
2887
|
-
### When Storybook Differs from Figma
|
|
2888
|
-
|
|
2889
|
-
1. **Check Storybook first** - It shows real, implemented components
|
|
2890
|
-
2. **Use closest existing variant** - Don't create one-off font sizes/colors
|
|
2891
|
-
3. **Confirm changes are intentional** - Consult with team before updating components
|
|
2892
|
-
4. **Create follow-up ticket** - If component needs updating but you're short on time
|
|
2893
|
-
5. **Make changes system-wide** - Component updates should benefit entire app
|
|
2894
|
-
|
|
2895
|
-
### Process
|
|
2896
|
-
|
|
2897
|
-
- **Minor differences** (font sizes, colors) → Stick to Storybook
|
|
2898
|
-
- **Component looks different** → Confirm with team, update component intentionally
|
|
2899
|
-
- **Missing component** → Check with team - it may exist with a different name
|
|
2900
|
-
|
|
2901
|
-
## Use Internal Components
|
|
2902
|
-
|
|
2903
|
-
### ✅ ALWAYS USE Project Wrappers
|
|
2904
|
-
|
|
2905
|
-
Instead of importing directly from `@mui/material`, use project wrappers:
|
|
2906
|
-
|
|
2907
|
-
```typescript
|
|
2908
|
-
// ❌ Don't
|
|
2909
|
-
import { Button, IconButton } from "@mui/material";
|
|
2910
|
-
|
|
2911
|
-
// ✅ Do
|
|
2912
|
-
import { Button } from "@/components/Button";
|
|
2913
|
-
import { IconButton } from "@clipboard-health/ui-components";
|
|
2914
|
-
```
|
|
2915
|
-
|
|
2916
|
-
### Component Wrapper List (Example)
|
|
2917
|
-
|
|
2918
|
-
Common components that may have wrappers:
|
|
2919
|
-
|
|
2920
|
-
- `Button`, `LoadingButton`, `IconButton`
|
|
2921
|
-
- `Avatar`, `Accordion`, `Badge`
|
|
2922
|
-
- `Card`, `Chip`, `Dialog`
|
|
2923
|
-
- `Drawer`, `List`, `ListItem`
|
|
2924
|
-
- `Rating`, `Slider`, `Switch`
|
|
2925
|
-
- `TextField`, `Typography`, `Tab`, `Tabs`
|
|
2926
|
-
|
|
2927
|
-
Check your project's ESLint configuration for the full list.
|
|
2928
|
-
|
|
2929
|
-
## Icons
|
|
2930
|
-
|
|
2931
|
-
### Use Project Icon Component
|
|
2932
|
-
|
|
2933
|
-
```typescript
|
|
2934
|
-
// ❌ Don't use third-party icons directly
|
|
2935
|
-
import SearchIcon from '@mui/icons-material/Search';
|
|
2936
|
-
|
|
2937
|
-
// ✅ Use project's icon system
|
|
2938
|
-
import { CbhIcon } from '@clipboard-health/ui-components';
|
|
2939
|
-
|
|
2940
|
-
<CbhIcon type="search" size="large" />
|
|
2941
|
-
<CbhIcon type="search-colored" size="medium" />
|
|
2942
|
-
```
|
|
2943
|
-
|
|
2944
|
-
### Icon Variants
|
|
2945
|
-
|
|
2946
|
-
- Many icon systems have variants for different states (e.g., `-colored` for active states)
|
|
2947
|
-
- Check your project's icon documentation for available variants
|
|
2948
|
-
|
|
2949
|
-
## Styling with sx Prop
|
|
2950
|
-
|
|
2951
|
-
### Basic Usage - Type-Safe Theme Access
|
|
2952
|
-
|
|
2953
|
-
❌ **Never** hardcode values or use string paths:
|
|
2954
|
-
|
|
2955
|
-
```typescript
|
|
2956
|
-
<Box
|
|
2957
|
-
sx={{
|
|
2958
|
-
backgroundColor: "red", // ❌ Raw color
|
|
2959
|
-
color: "#ADFF11", // ❌ Hex code
|
|
2960
|
-
padding: "16px", // ❌ Raw size
|
|
2961
|
-
color: "text.secondary", // ❌ String path (no TypeScript support)
|
|
2962
|
-
}}
|
|
2963
|
-
/>
|
|
2964
|
-
```
|
|
2965
|
-
|
|
2966
|
-
✅ **Always** use theme with type safety:
|
|
2967
|
-
|
|
2968
|
-
```typescript
|
|
2969
|
-
<Box
|
|
2970
|
-
sx={(theme) => ({
|
|
2971
|
-
backgroundColor: theme.palette.background.primary, // ✅ Semantic token
|
|
2972
|
-
color: theme.palette.text.secondary, // ✅ Type-safe
|
|
2973
|
-
padding: theme.spacing(4), // or just: padding: 4 // ✅ Spacing system
|
|
2974
|
-
})}
|
|
2975
|
-
/>
|
|
2976
|
-
```
|
|
2977
|
-
|
|
2978
|
-
### Use Meaningful Tokens
|
|
2979
|
-
|
|
2980
|
-
❌ **Avoid** non-descriptive tokens:
|
|
2981
|
-
|
|
2982
|
-
```typescript
|
|
2983
|
-
theme.palette.common.white; // ❌ No context about usage
|
|
2984
|
-
theme.palette.green300; // ❌ Which green? When to use?
|
|
2985
|
-
```
|
|
2986
|
-
|
|
2987
|
-
✅ **Use** semantic tokens:
|
|
2988
|
-
|
|
2989
|
-
```typescript
|
|
2990
|
-
theme.palette.background.tertiary; // ✅ Clear purpose
|
|
2991
|
-
theme.palette.instantPay.background; // ✅ Intent is obvious
|
|
2992
|
-
theme.palette.text.primary; // ✅ Meaningful
|
|
2993
|
-
```
|
|
2994
|
-
|
|
2995
|
-
## Spacing System
|
|
2996
|
-
|
|
2997
|
-
We use a strict index-based spacing system:
|
|
2998
|
-
|
|
2999
|
-
| Index | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|
|
3000
|
-
| ----- | --- | --- | --- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
|
|
3001
|
-
| Size | 4px | 6px | 8px | 12px | 16px | 20px | 24px | 32px | 40px | 48px | 56px | 64px |
|
|
3002
|
-
|
|
3003
|
-
**Usage:**
|
|
3004
|
-
|
|
3005
|
-
```typescript
|
|
3006
|
-
<Box sx={{ padding: 5 }} /> // → 16px
|
|
3007
|
-
<Box sx={{ marginX: 4 }} /> // → 12px left and right
|
|
3008
|
-
<Box sx={{ gap: 3 }} /> // → 8px
|
|
3009
|
-
```
|
|
3010
|
-
|
|
3011
|
-
**Use `rem` for fonts and heights:**
|
|
3012
|
-
|
|
3013
|
-
```typescript
|
|
3014
|
-
<Box
|
|
3015
|
-
sx={(theme) => ({
|
|
3016
|
-
height: "3rem", // ✅ Scales with user zoom
|
|
3017
|
-
fontSize: theme.typography.body1.fontSize, // ✅ From theme
|
|
3018
|
-
padding: 5, // ✅ px prevents overflow when zoomed
|
|
3019
|
-
})}
|
|
3020
|
-
/>
|
|
3021
|
-
```
|
|
3022
|
-
|
|
3023
|
-
**Reasoning:** Users who adjust device-wide zoom need `rem` for fonts/heights to scale properly, but `px` spacing prevents layout overflow.
|
|
3024
|
-
|
|
3025
|
-
## Theme Integration
|
|
3026
|
-
|
|
3027
|
-
### Responsive Styles
|
|
3028
|
-
|
|
3029
|
-
```typescript
|
|
3030
|
-
<Box
|
|
3031
|
-
sx={{
|
|
3032
|
-
width: {
|
|
3033
|
-
xs: "100%", // Mobile
|
|
3034
|
-
sm: "75%", // Tablet
|
|
3035
|
-
md: "50%", // Desktop
|
|
3036
|
-
},
|
|
3037
|
-
padding: {
|
|
3038
|
-
xs: 1,
|
|
3039
|
-
md: 3,
|
|
3040
|
-
},
|
|
3041
|
-
}}
|
|
3042
|
-
/>
|
|
3043
|
-
```
|
|
3044
|
-
|
|
3045
|
-
### Pseudo-classes and Hover States
|
|
3046
|
-
|
|
3047
|
-
```typescript
|
|
3048
|
-
<Box
|
|
3049
|
-
sx={(theme) => ({
|
|
3050
|
-
"&:hover": {
|
|
3051
|
-
backgroundColor: theme.palette.primary.dark,
|
|
3052
|
-
cursor: "pointer",
|
|
3053
|
-
},
|
|
3054
|
-
"&:disabled": {
|
|
3055
|
-
opacity: 0.5,
|
|
3056
|
-
},
|
|
3057
|
-
"&.active": {
|
|
3058
|
-
borderColor: theme.palette.primary.main,
|
|
3059
|
-
},
|
|
3060
|
-
})}
|
|
3061
|
-
/>
|
|
3062
|
-
```
|
|
3063
|
-
|
|
3064
|
-
### Nested Selectors
|
|
3065
|
-
|
|
3066
|
-
```typescript
|
|
3067
|
-
<Box
|
|
3068
|
-
sx={(theme) => ({
|
|
3069
|
-
"& .child-element": {
|
|
3070
|
-
color: theme.palette.text.secondary,
|
|
3071
|
-
},
|
|
3072
|
-
"& > div": {
|
|
3073
|
-
marginBottom: 1,
|
|
3074
|
-
},
|
|
3075
|
-
// Target nested MUI components
|
|
3076
|
-
"& .MuiTypography-root": {
|
|
3077
|
-
color: theme.palette.intent?.disabled.text,
|
|
3078
|
-
},
|
|
3079
|
-
})}
|
|
3080
|
-
/>
|
|
3081
|
-
```
|
|
3082
|
-
|
|
3083
|
-
## Shorthand Properties
|
|
3084
|
-
|
|
3085
|
-
MUI provides shorthand properties - use full names, not abbreviations:
|
|
3086
|
-
|
|
3087
|
-
✅ **Use full names:**
|
|
3088
|
-
|
|
3089
|
-
```typescript
|
|
3090
|
-
<Box
|
|
3091
|
-
sx={{
|
|
3092
|
-
padding: 2, // ✅ Clear
|
|
3093
|
-
paddingX: 4, // ✅ Readable
|
|
3094
|
-
marginY: 2, // ✅ Explicit
|
|
3095
|
-
}}
|
|
3096
|
-
/>
|
|
3097
|
-
```
|
|
3098
|
-
|
|
3099
|
-
❌ **Avoid abbreviations** (per naming conventions best practice):
|
|
3100
|
-
|
|
3101
|
-
```typescript
|
|
3102
|
-
<Box
|
|
3103
|
-
sx={{
|
|
3104
|
-
p: 2, // ❌ Too terse
|
|
3105
|
-
px: 4, // ❌ Not clear
|
|
3106
|
-
my: 2, // ❌ What does this mean?
|
|
3107
|
-
}}
|
|
3108
|
-
/>
|
|
3109
|
-
```
|
|
3110
|
-
|
|
3111
|
-
[Full list of shorthand properties](https://mui.com/system/properties/)
|
|
3112
|
-
|
|
3113
|
-
## Merging sx Props
|
|
3114
|
-
|
|
3115
|
-
For generic components accepting an `sx` prop, merge default styles with custom styles:
|
|
3116
|
-
|
|
3117
|
-
```typescript
|
|
3118
|
-
// Option 1: Array syntax (MUI native)
|
|
3119
|
-
<Box
|
|
3120
|
-
sx={[
|
|
3121
|
-
(theme) => ({
|
|
3122
|
-
backgroundColor: theme.palette.background.tertiary,
|
|
3123
|
-
padding: 2,
|
|
3124
|
-
}),
|
|
3125
|
-
sx, // User's custom sx prop
|
|
3126
|
-
]}
|
|
3127
|
-
{...restProps}
|
|
3128
|
-
/>
|
|
3129
|
-
|
|
3130
|
-
// Option 2: Use a utility function if your project provides one
|
|
3131
|
-
import { mergeSxProps } from "@clipboard-health/ui-components";
|
|
3132
|
-
|
|
3133
|
-
<Box
|
|
3134
|
-
sx={mergeSxProps(defaultStyles, sx)}
|
|
3135
|
-
{...restProps}
|
|
3136
|
-
/>;
|
|
3137
|
-
```
|
|
3138
|
-
|
|
3139
|
-
## Theme Access
|
|
3140
|
-
|
|
3141
|
-
### Using useTheme Hook
|
|
3142
|
-
|
|
3143
|
-
```typescript
|
|
3144
|
-
import { useTheme } from "@mui/material";
|
|
3145
|
-
|
|
3146
|
-
export function Component() {
|
|
3147
|
-
const theme = useTheme();
|
|
3148
|
-
|
|
3149
|
-
return (
|
|
3150
|
-
<Box sx={{ color: theme.palette.primary.main }}>
|
|
3151
|
-
{/* Your components */}
|
|
3152
|
-
</Box>
|
|
3153
|
-
);
|
|
3154
|
-
}
|
|
3155
|
-
```
|
|
3156
|
-
|
|
3157
|
-
### Theme Properties
|
|
3158
|
-
|
|
3159
|
-
```typescript
|
|
3160
|
-
const theme = useTheme();
|
|
3161
|
-
|
|
3162
|
-
// Colors
|
|
3163
|
-
theme.palette.primary.main;
|
|
3164
|
-
theme.palette.secondary.main;
|
|
3165
|
-
theme.palette.error.main;
|
|
3166
|
-
theme.palette.text.primary;
|
|
3167
|
-
theme.palette.background.default;
|
|
3168
|
-
|
|
3169
|
-
// Spacing
|
|
3170
|
-
theme.spacing(1); // Project-defined (see spacing system above)
|
|
3171
|
-
theme.spacing(2); // Project-defined (see spacing system above)
|
|
3172
|
-
|
|
3173
|
-
// Typography
|
|
3174
|
-
theme.typography.h1;
|
|
3175
|
-
theme.typography.body1;
|
|
3176
|
-
|
|
3177
|
-
// Breakpoints
|
|
3178
|
-
theme.breakpoints.up("md");
|
|
3179
|
-
theme.breakpoints.down("sm");
|
|
3180
|
-
```
|
|
3181
|
-
|
|
3182
|
-
## Modal Patterns
|
|
3183
|
-
|
|
3184
|
-
### ❌ Avoid Generic Modal
|
|
3185
|
-
|
|
3186
|
-
```typescript
|
|
3187
|
-
// Avoid generic Modal when project has specific dialog components
|
|
3188
|
-
import { Modal } from "@mui/material";
|
|
3189
|
-
```
|
|
3190
|
-
|
|
3191
|
-
### ✅ Use Project-Specific Dialog Components
|
|
3192
|
-
|
|
3193
|
-
```typescript
|
|
3194
|
-
// For mobile-friendly modals
|
|
3195
|
-
import { BottomSheet } from "@/components/BottomSheet";
|
|
3196
|
-
|
|
3197
|
-
// For full-screen views
|
|
3198
|
-
import { FullScreenDialog } from "@/components/FullScreenDialog";
|
|
3199
|
-
|
|
3200
|
-
// For standard dialogs
|
|
3201
|
-
import { Dialog } from "@/components/Dialog";
|
|
3202
|
-
```
|
|
3203
|
-
|
|
3204
|
-
Check your project's component library for available dialog/modal components.
|
|
3205
|
-
|
|
3206
|
-
## Layout Components
|
|
3207
|
-
|
|
3208
|
-
### Use MUI Layout Components
|
|
3209
|
-
|
|
3210
|
-
These are safe to import directly from MUI:
|
|
3211
|
-
|
|
3212
|
-
```typescript
|
|
3213
|
-
import { Box, Stack, Container, Grid } from '@mui/material';
|
|
3214
|
-
|
|
3215
|
-
// Stack for vertical/horizontal layouts
|
|
3216
|
-
<Stack spacing={2} direction="row">
|
|
3217
|
-
<Item />
|
|
3218
|
-
<Item />
|
|
3219
|
-
</Stack>
|
|
3220
|
-
|
|
3221
|
-
// Box for flexible containers
|
|
3222
|
-
<Box sx={{ display: 'flex', gap: 2 }}>
|
|
3223
|
-
<Child />
|
|
3224
|
-
</Box>
|
|
3225
|
-
|
|
3226
|
-
// Grid for responsive layouts
|
|
3227
|
-
<Grid container spacing={2}>
|
|
3228
|
-
<Grid item xs={12} md={6}>
|
|
3229
|
-
<Content />
|
|
3230
|
-
</Grid>
|
|
3231
|
-
</Grid>
|
|
3232
|
-
```
|
|
3233
|
-
|
|
3234
|
-
## MUI Theme Augmentation
|
|
3235
|
-
|
|
3236
|
-
### Type Safety
|
|
3237
|
-
|
|
3238
|
-
Projects often augment MUI's theme with custom properties:
|
|
3239
|
-
|
|
3240
|
-
```typescript
|
|
3241
|
-
// Example: Custom theme augmentation
|
|
3242
|
-
declare module "@mui/material/styles" {
|
|
3243
|
-
interface Theme {
|
|
3244
|
-
customSpacing: {
|
|
3245
|
-
small: string;
|
|
3246
|
-
large: string;
|
|
3247
|
-
};
|
|
3248
|
-
}
|
|
3249
|
-
interface ThemeOptions {
|
|
3250
|
-
customSpacing?: {
|
|
3251
|
-
small?: string;
|
|
3252
|
-
large?: string;
|
|
3253
|
-
};
|
|
3254
|
-
}
|
|
3255
|
-
}
|
|
3256
|
-
```
|
|
3257
|
-
|
|
3258
|
-
Check your project's type definitions for available theme augmentations.
|
|
3259
|
-
|
|
3260
|
-
## Common Patterns
|
|
3261
|
-
|
|
3262
|
-
### Card with Custom Styling
|
|
3263
|
-
|
|
3264
|
-
```typescript
|
|
3265
|
-
import { Card, CardContent } from "@mui/material";
|
|
3266
|
-
|
|
3267
|
-
<Card
|
|
3268
|
-
sx={{
|
|
3269
|
-
borderRadius: 2,
|
|
3270
|
-
boxShadow: 2,
|
|
3271
|
-
"&:hover": {
|
|
3272
|
-
boxShadow: 4,
|
|
3273
|
-
},
|
|
3274
|
-
}}
|
|
3275
|
-
>
|
|
3276
|
-
<CardContent>Content here</CardContent>
|
|
3277
|
-
</Card>;
|
|
3278
|
-
```
|
|
3279
|
-
|
|
3280
|
-
### Buttons with Theme Colors
|
|
3281
|
-
|
|
3282
|
-
```typescript
|
|
3283
|
-
import { Button } from "@/components/Button";
|
|
3284
|
-
|
|
3285
|
-
<Button
|
|
3286
|
-
variant="contained"
|
|
3287
|
-
color="primary"
|
|
3288
|
-
sx={{
|
|
3289
|
-
textTransform: "none", // Override uppercase
|
|
3290
|
-
fontWeight: "bold",
|
|
3291
|
-
}}
|
|
3292
|
-
>
|
|
3293
|
-
Submit
|
|
3294
|
-
</Button>;
|
|
3295
|
-
```
|
|
3296
|
-
|
|
3297
|
-
### Conditional Styles
|
|
3298
|
-
|
|
3299
|
-
```typescript
|
|
3300
|
-
<Box
|
|
3301
|
-
sx={(theme) => ({
|
|
3302
|
-
backgroundColor: isActive
|
|
3303
|
-
? theme.palette.primary.main
|
|
3304
|
-
: theme.palette.grey[200],
|
|
3305
|
-
padding: 2,
|
|
3306
|
-
})}
|
|
3307
|
-
>
|
|
3308
|
-
{content}
|
|
3309
|
-
</Box>
|
|
3310
|
-
```
|
|
3311
|
-
|
|
3312
|
-
## Best Practices
|
|
3313
|
-
|
|
3314
|
-
- **Check Storybook first** - It's the single source of truth
|
|
3315
|
-
- **Use theme tokens** - Never hardcode colors/spacing
|
|
3316
|
-
- **Type-safe access** - Function form: `sx={(theme) => ({...})}`
|
|
3317
|
-
- **Meaningful tokens** - Semantic names over raw colors
|
|
3318
|
-
- **Spacing system** - Indices 1-12 (or `theme.spacing(n)`)
|
|
3319
|
-
- **Use shorthand props** - `paddingX`, `marginY` (full names, not `px`, `my`)
|
|
3320
|
-
- **Leverage pseudo-classes** - For hover, focus, disabled states
|
|
3321
|
-
- **Prefer `sx` over direct props** - `sx` takes priority and is more flexible
|
|
3322
|
-
|
|
3323
|
-
<!-- Source: .ruler/frontend/testing.md -->
|
|
3324
|
-
|
|
3325
|
-
# Testing Standards
|
|
3326
|
-
|
|
3327
|
-
## The Testing Trophy Philosophy
|
|
3328
|
-
|
|
3329
|
-
Our testing strategy follows the **Testing Trophy** model:
|
|
3330
|
-
|
|
3331
|
-
```text
|
|
3332
|
-
/\_
|
|
3333
|
-
/E2E\ ← End-to-End (smallest layer)
|
|
3334
|
-
/-----\
|
|
3335
|
-
/ Integ \ ← Integration (largest layer - FOCUS HERE!)
|
|
3336
|
-
/---------\
|
|
3337
|
-
/ Unit \ ← Unit Tests (helpers/utilities only)
|
|
3338
|
-
/-------------\
|
|
3339
|
-
/ Static \ ← TypeScript + ESLint (foundation)
|
|
3340
|
-
```
|
|
3341
|
-
|
|
3342
|
-
**Investment Priority:**
|
|
3343
|
-
|
|
3344
|
-
1. **Static** (TypeScript/ESLint) - Free confidence, catches typos and type errors
|
|
3345
|
-
2. **Integration** - Most valuable, test how components work together as users experience them
|
|
3346
|
-
3. **Unit** - For pure helpers/utilities, NOT UI components
|
|
3347
|
-
4. **E2E** - Critical user flows only, slow and expensive
|
|
3348
|
-
|
|
3349
|
-
**Key Principle:** Test as close to how users interact with your app as possible. Users don't shallow-render components or call functions in isolation - they interact with features.
|
|
3350
|
-
|
|
3351
|
-
## Technology Stack
|
|
3352
|
-
|
|
3353
|
-
- **Vitest** for test runner
|
|
3354
|
-
- **@testing-library/react** for component testing
|
|
3355
|
-
- **@testing-library/user-event** for user interactions
|
|
3356
|
-
- **Mock Service Worker (MSW)** for API mocking
|
|
3357
|
-
|
|
3358
|
-
## Test File Structure
|
|
3359
|
-
|
|
3360
|
-
```typescript
|
|
3361
|
-
import { render, screen, waitFor } from "@testing-library/react";
|
|
3362
|
-
import { renderHook } from "@testing-library/react";
|
|
3363
|
-
import userEvent from "@testing-library/user-event";
|
|
3364
|
-
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3365
|
-
|
|
3366
|
-
describe("ComponentName", () => {
|
|
3367
|
-
beforeEach(() => {
|
|
3368
|
-
vi.clearAllMocks();
|
|
3369
|
-
});
|
|
3370
|
-
|
|
3371
|
-
it("should render correctly", () => {
|
|
3372
|
-
render(<Component />);
|
|
3373
|
-
expect(screen.getByText("Expected")).toBeInTheDocument();
|
|
3374
|
-
});
|
|
3375
|
-
|
|
3376
|
-
it("should handle user interaction", async () => {
|
|
3377
|
-
const user = userEvent.setup();
|
|
3378
|
-
render(<Component />);
|
|
3379
|
-
|
|
3380
|
-
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
3381
|
-
|
|
3382
|
-
await waitFor(() => {
|
|
3383
|
-
expect(screen.getByText("Success")).toBeInTheDocument();
|
|
3384
|
-
});
|
|
3385
|
-
});
|
|
3386
|
-
});
|
|
3387
|
-
```
|
|
3388
|
-
|
|
3389
|
-
## Test Naming Conventions
|
|
3390
|
-
|
|
3391
|
-
### Describe Blocks
|
|
3392
|
-
|
|
3393
|
-
- Use the component/function name: `describe('ComponentName', ...)`
|
|
3394
|
-
- Nest describe blocks for complex scenarios
|
|
3395
|
-
|
|
3396
|
-
```typescript
|
|
3397
|
-
describe("ShiftCard", () => {
|
|
3398
|
-
describe("when shift is urgent", () => {
|
|
3399
|
-
it("should display urgent badge", () => {
|
|
3400
|
-
// ...
|
|
3401
|
-
});
|
|
3402
|
-
});
|
|
3403
|
-
|
|
3404
|
-
describe("when shift is booked", () => {
|
|
3405
|
-
it("should show booked status", () => {
|
|
3406
|
-
// ...
|
|
3407
|
-
});
|
|
3408
|
-
});
|
|
3409
|
-
});
|
|
3410
|
-
```
|
|
3411
|
-
|
|
3412
|
-
### Test Names
|
|
3413
|
-
|
|
3414
|
-
- Pattern: `'should [expected behavior] when [condition]'`
|
|
3415
|
-
- Be descriptive and specific
|
|
3416
|
-
|
|
3417
|
-
```typescript
|
|
3418
|
-
// Good
|
|
3419
|
-
it("should show loading spinner when data is fetching", () => {});
|
|
3420
|
-
it("should display error message when API call fails", () => {});
|
|
3421
|
-
it("should enable submit button when form is valid", () => {});
|
|
3422
|
-
|
|
3423
|
-
// Avoid
|
|
3424
|
-
it("works", () => {});
|
|
3425
|
-
it("loading state", () => {});
|
|
3426
|
-
```
|
|
3427
|
-
|
|
3428
|
-
## Parameterized Tests
|
|
3429
|
-
|
|
3430
|
-
### Using it.each
|
|
3431
|
-
|
|
3432
|
-
```typescript
|
|
3433
|
-
it.each([
|
|
3434
|
-
{ input: { isUrgent: true }, expected: "URGENT" },
|
|
3435
|
-
{ input: { isUrgent: false }, expected: "REGULAR" },
|
|
3436
|
-
])("should return $expected when isUrgent is $input.isUrgent", ({ input, expected }) => {
|
|
3437
|
-
expect(getShiftType(input)).toBe(expected);
|
|
3438
|
-
});
|
|
3439
|
-
```
|
|
3440
|
-
|
|
3441
|
-
### Table-Driven Tests
|
|
3442
|
-
|
|
3443
|
-
```typescript
|
|
3444
|
-
describe("calculateShiftPay", () => {
|
|
3445
|
-
it.each([
|
|
3446
|
-
{ hours: 8, rate: 30, expected: 240 },
|
|
3447
|
-
{ hours: 10, rate: 25, expected: 250 },
|
|
3448
|
-
{ hours: 12, rate: 35, expected: 420 },
|
|
3449
|
-
])("should calculate $expected for $hours hours at $rate/hr", ({ hours, rate, expected }) => {
|
|
3450
|
-
expect(calculateShiftPay(hours, rate)).toBe(expected);
|
|
3451
|
-
});
|
|
3452
|
-
});
|
|
3453
|
-
```
|
|
3454
|
-
|
|
3455
|
-
## Component Testing (Integration Tests)
|
|
3456
|
-
|
|
3457
|
-
Integration tests form the largest part of the Testing Trophy. Test features, not isolated components.
|
|
3458
|
-
|
|
3459
|
-
### Rendering Components
|
|
3460
|
-
|
|
3461
|
-
```typescript
|
|
3462
|
-
import { render, screen } from "@testing-library/react";
|
|
3463
|
-
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3464
|
-
|
|
3465
|
-
function renderWithProviders(component: ReactElement) {
|
|
3466
|
-
const queryClient = new QueryClient({
|
|
3467
|
-
defaultOptions: {
|
|
3468
|
-
queries: { retry: false },
|
|
3469
|
-
},
|
|
3470
|
-
});
|
|
3471
|
-
|
|
3472
|
-
return render(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>);
|
|
3473
|
-
}
|
|
3474
|
-
|
|
3475
|
-
it("should render user name", () => {
|
|
3476
|
-
renderWithProviders(<UserProfile userId="123" />);
|
|
3477
|
-
expect(screen.getByText("John Doe")).toBeInTheDocument();
|
|
3478
|
-
});
|
|
3479
|
-
```
|
|
3480
|
-
|
|
3481
|
-
### Querying Elements - Priority Order
|
|
3482
|
-
|
|
3483
|
-
Follow [Testing Library's query priority](https://testing-library.com/docs/queries/about#priority):
|
|
3484
|
-
|
|
3485
|
-
1. **`getByRole`** - Best for accessibility (buttons, links, inputs)
|
|
3486
|
-
2. **`getByLabelText`** - For form fields with labels
|
|
3487
|
-
3. **`getByPlaceholderText`** - For inputs without labels
|
|
3488
|
-
4. **`getByText`** - For non-interactive content
|
|
3489
|
-
5. **`getByDisplayValue`** - For current input values
|
|
3490
|
-
6. **`getByAltText`** - For images
|
|
3491
|
-
7. **`getByTitle`** - Less common
|
|
3492
|
-
8. **`getByTestId`** - ⚠️ **LAST RESORT** - Use only when no other option exists
|
|
3493
|
-
|
|
3494
|
-
```typescript
|
|
3495
|
-
// ✅ Prefer accessible queries
|
|
3496
|
-
screen.getByRole("button", { name: /submit/i });
|
|
3497
|
-
screen.getByLabelText("Email address");
|
|
3498
|
-
screen.getByText("Welcome back");
|
|
3499
|
-
|
|
3500
|
-
// ❌ Avoid CSS selectors and implementation details
|
|
3501
|
-
screen.getByClassName("user-card"); // Users don't see classes
|
|
3502
|
-
wrapper.find("UserCard").prop("user"); // Testing implementation
|
|
3503
|
-
screen.getByTestId("custom-element"); // Last resort only
|
|
3504
|
-
```
|
|
3505
|
-
|
|
3506
|
-
### User Interactions
|
|
3507
|
-
|
|
3508
|
-
```typescript
|
|
3509
|
-
import userEvent from "@testing-library/user-event";
|
|
3510
|
-
|
|
3511
|
-
it("should handle form submission", async () => {
|
|
3512
|
-
const user = userEvent.setup();
|
|
3513
|
-
const onSubmit = vi.fn();
|
|
3514
|
-
|
|
3515
|
-
render(<Form onSubmit={onSubmit} />);
|
|
3516
|
-
|
|
3517
|
-
// Type in input
|
|
3518
|
-
await user.type(screen.getByLabelText("Name"), "John Doe");
|
|
3519
|
-
|
|
3520
|
-
// Click button
|
|
3521
|
-
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
3522
|
-
|
|
3523
|
-
// Assert
|
|
3524
|
-
expect(onSubmit).toHaveBeenCalledWith({ name: "John Doe" });
|
|
3525
|
-
});
|
|
3526
|
-
```
|
|
3527
|
-
|
|
3528
|
-
## Hook Testing (Unit Tests)
|
|
712
|
+
## Best Practices
|
|
3529
713
|
|
|
3530
|
-
|
|
714
|
+
- **Single Responsibility** - One component, one purpose
|
|
715
|
+
- **Composition over Props** - Use children and compound components
|
|
716
|
+
- **Colocate State** - Keep state close to where it's used
|
|
717
|
+
- **Type Everything** - Full TypeScript coverage
|
|
718
|
+
- **Test Behavior** - Test user interactions, not implementation
|
|
3531
719
|
|
|
3532
|
-
|
|
720
|
+
<!-- Source: .ruler/frontend/react.md -->
|
|
3533
721
|
|
|
3534
|
-
|
|
3535
|
-
import { renderHook, waitFor } from "@testing-library/react";
|
|
722
|
+
# React
|
|
3536
723
|
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
724
|
+
- Destructure props in function body (improves readability and prop documentation)
|
|
725
|
+
- Prefer inline JSX over extracted variables
|
|
726
|
+
- Use custom hooks to encapsulate and reuse stateful logic
|
|
727
|
+
- Use Zod for request/response schemas in data-fetching hooks
|
|
728
|
+
- Use react-hook-form with Zod resolver for forms
|
|
729
|
+
- Use date-fns for date operations
|
|
3540
730
|
|
|
3541
|
-
|
|
3542
|
-
});
|
|
731
|
+
<!-- Source: .ruler/frontend/styling.md -->
|
|
3543
732
|
|
|
3544
|
-
|
|
3545
|
-
const { result } = renderHook(() => useCustomHook());
|
|
733
|
+
# Styling Standards
|
|
3546
734
|
|
|
3547
|
-
|
|
3548
|
-
expect(result.current.isLoading).toBe(false);
|
|
3549
|
-
});
|
|
735
|
+
## Core Principles
|
|
3550
736
|
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
})
|
|
3554
|
-
|
|
737
|
+
1. **Always use `sx` prop** - Never CSS/SCSS/SASS files
|
|
738
|
+
2. **Use theme tokens** - Never hardcode colors/spacing
|
|
739
|
+
3. **Type-safe theme access** - Use `sx={(theme) => ({...})}`
|
|
740
|
+
4. **Use semantic names** - `theme.palette.text.primary`, not `common.white`
|
|
741
|
+
5. **Check Storybook first** - It's the single source of truth
|
|
3555
742
|
|
|
3556
|
-
|
|
743
|
+
## Restricted Patterns
|
|
3557
744
|
|
|
3558
|
-
|
|
3559
|
-
it("should update when dependencies change", async () => {
|
|
3560
|
-
const { result, rerender } = renderHook(({ userId }) => useGetUser(userId), {
|
|
3561
|
-
initialProps: { userId: "1" },
|
|
3562
|
-
});
|
|
745
|
+
❌ **DO NOT USE:**
|
|
3563
746
|
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
747
|
+
- `styled()` or `makeStyles()` from MUI
|
|
748
|
+
- CSS/SCSS/SASS files
|
|
749
|
+
- Inline `style` prop (use `sx` instead)
|
|
750
|
+
- String paths for theme (`"text.secondary"` - not type-safe)
|
|
3567
751
|
|
|
3568
|
-
|
|
3569
|
-
rerender({ userId: "2" });
|
|
752
|
+
## Type-Safe Theme Access
|
|
3570
753
|
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
754
|
+
```typescript
|
|
755
|
+
// ✅ Always use function form for type safety
|
|
756
|
+
<Box sx={(theme) => ({
|
|
757
|
+
backgroundColor: theme.palette.background.primary,
|
|
758
|
+
color: theme.palette.text.secondary,
|
|
759
|
+
padding: theme.spacing(4), // or just: 4
|
|
760
|
+
})} />
|
|
761
|
+
|
|
762
|
+
// ❌ Never hardcode
|
|
763
|
+
<Box sx={{
|
|
764
|
+
backgroundColor: "red", // ❌ Raw color
|
|
765
|
+
padding: "16px", // ❌ Raw size
|
|
766
|
+
color: "text.secondary", // ❌ String path
|
|
767
|
+
}} />
|
|
3575
768
|
```
|
|
3576
769
|
|
|
3577
|
-
##
|
|
770
|
+
## Spacing System
|
|
3578
771
|
|
|
3579
|
-
|
|
772
|
+
Use indices 1-12 (4px-64px):
|
|
3580
773
|
|
|
3581
|
-
|
|
774
|
+
```typescript
|
|
775
|
+
<Box sx={{ padding: 5 }} /> // → 16px
|
|
776
|
+
<Box sx={{ marginX: 4 }} /> // → 12px left and right
|
|
777
|
+
<Box sx={{ gap: 3 }} /> // → 8px
|
|
778
|
+
```
|
|
3582
779
|
|
|
3583
|
-
|
|
780
|
+
**Use `rem` for fonts/heights (scales with user zoom), `px` for spacing:**
|
|
3584
781
|
|
|
3585
782
|
```typescript
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
)
|
|
783
|
+
<Box sx={(theme) => ({
|
|
784
|
+
height: "3rem", // ✅ Scales
|
|
785
|
+
fontSize: theme.typography.body1.fontSize,
|
|
786
|
+
padding: 5, // ✅ Prevents overflow
|
|
787
|
+
})} />
|
|
3591
788
|
```
|
|
3592
789
|
|
|
3593
|
-
|
|
790
|
+
## Responsive Styles
|
|
3594
791
|
|
|
3595
792
|
```typescript
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
async (_req, res, ctx) => {
|
|
3601
|
-
return res(ctx.status(200), ctx.json(facilityNotes));
|
|
3602
|
-
},
|
|
3603
|
-
);
|
|
3604
|
-
};
|
|
3605
|
-
|
|
3606
|
-
// Export default success scenario for convenience
|
|
3607
|
-
export const facilityNotesTestHandlers = [createFacilityNotesTestHandler(mockFacilityNotes)];
|
|
793
|
+
<Box sx={{
|
|
794
|
+
width: { xs: "100%", md: "50%" },
|
|
795
|
+
padding: { xs: 1, md: 3 },
|
|
796
|
+
}} />
|
|
3608
797
|
```
|
|
3609
798
|
|
|
3610
|
-
|
|
799
|
+
## Pseudo-classes
|
|
3611
800
|
|
|
3612
801
|
```typescript
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
)
|
|
802
|
+
<Box sx={(theme) => ({
|
|
803
|
+
"&:hover": { backgroundColor: theme.palette.primary.dark },
|
|
804
|
+
"&:disabled": { opacity: 0.5 },
|
|
805
|
+
"& .child": { color: theme.palette.text.secondary },
|
|
806
|
+
})} />
|
|
3618
807
|
```
|
|
3619
808
|
|
|
3620
|
-
|
|
809
|
+
## Shorthand Properties
|
|
3621
810
|
|
|
3622
|
-
|
|
811
|
+
✅ Use full names: `padding`, `paddingX`, `marginY`
|
|
812
|
+
❌ Avoid abbreviations: `p`, `px`, `my`
|
|
813
|
+
|
|
814
|
+
## Layout Components
|
|
3623
815
|
|
|
3624
|
-
|
|
816
|
+
Safe to import directly from MUI:
|
|
3625
817
|
|
|
3626
818
|
```typescript
|
|
3627
|
-
import {
|
|
3628
|
-
|
|
819
|
+
import { Box, Stack, Container, Grid } from "@mui/material";
|
|
820
|
+
```
|
|
3629
821
|
|
|
3630
|
-
|
|
3631
|
-
vi.mock("@/features/user/hooks/useUser");
|
|
822
|
+
## Best Practices
|
|
3632
823
|
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
```
|
|
824
|
+
- Type-safe access with `sx={(theme) => ({...})}`
|
|
825
|
+
- Use semantic token names
|
|
826
|
+
- Full property names, not abbreviations
|
|
3637
827
|
|
|
3638
|
-
|
|
828
|
+
<!-- Source: .ruler/frontend/testing.md -->
|
|
3639
829
|
|
|
3640
|
-
|
|
3641
|
-
it("should call callback on success", async () => {
|
|
3642
|
-
const onSuccess = vi.fn();
|
|
830
|
+
# Testing Standards
|
|
3643
831
|
|
|
3644
|
-
|
|
832
|
+
## Testing Trophy Philosophy
|
|
3645
833
|
|
|
3646
|
-
|
|
834
|
+
Focus on **Integration tests** - test how components work together as users experience them.
|
|
3647
835
|
|
|
3648
|
-
|
|
3649
|
-
expect(onSuccess).toHaveBeenCalledWith(expectedData);
|
|
3650
|
-
});
|
|
3651
|
-
});
|
|
3652
|
-
```
|
|
836
|
+
**Investment Priority:**
|
|
3653
837
|
|
|
3654
|
-
|
|
838
|
+
1. **Static** (TypeScript/ESLint) - Free confidence
|
|
839
|
+
2. **Integration** - Most valuable, test features not isolated components
|
|
840
|
+
3. **Unit** - For pure helpers/utilities only
|
|
841
|
+
4. **E2E** - Critical flows only
|
|
3655
842
|
|
|
3656
|
-
|
|
3657
|
-
import { vi } from "vitest";
|
|
843
|
+
**Key Principle:** Test as close to how users interact with your app as possible.
|
|
3658
844
|
|
|
3659
|
-
|
|
3660
|
-
get: vi.fn().mockResolvedValue({
|
|
3661
|
-
data: { id: "1", name: "Test" },
|
|
3662
|
-
}),
|
|
3663
|
-
}));
|
|
3664
|
-
```
|
|
845
|
+
## Technology Stack
|
|
3665
846
|
|
|
3666
|
-
|
|
847
|
+
- **Vitest** for test runner
|
|
848
|
+
- **@testing-library/react** for component testing
|
|
849
|
+
- **@testing-library/user-event** for user interactions
|
|
850
|
+
- **Mock Service Worker (MSW)** for API mocking
|
|
3667
851
|
|
|
3668
|
-
|
|
852
|
+
## Test Structure
|
|
3669
853
|
|
|
3670
854
|
```typescript
|
|
3671
|
-
|
|
3672
|
-
render(
|
|
3673
|
-
|
|
3674
|
-
|
|
855
|
+
describe("ComponentName", () => {
|
|
856
|
+
it("should render correctly", () => {
|
|
857
|
+
render(<Component />);
|
|
858
|
+
expect(screen.getByText("Expected")).toBeInTheDocument();
|
|
859
|
+
});
|
|
3675
860
|
|
|
3676
|
-
|
|
3677
|
-
|
|
861
|
+
it("should handle user interaction", async () => {
|
|
862
|
+
const user = userEvent.setup();
|
|
863
|
+
render(<Component />);
|
|
864
|
+
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
865
|
+
await waitFor(() => expect(screen.getByText("Success")).toBeInTheDocument());
|
|
3678
866
|
});
|
|
3679
867
|
});
|
|
3680
868
|
```
|
|
3681
869
|
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
```typescript
|
|
3685
|
-
it("should display user name", async () => {
|
|
3686
|
-
render(<UserProfile />);
|
|
870
|
+
## Test Naming
|
|
3687
871
|
|
|
3688
|
-
|
|
3689
|
-
const name = await screen.findByText("John Doe");
|
|
3690
|
-
expect(name).toBeInTheDocument();
|
|
3691
|
-
});
|
|
3692
|
-
```
|
|
872
|
+
Pattern: `should [behavior] when [condition]`
|
|
3693
873
|
|
|
3694
|
-
##
|
|
874
|
+
## Querying Elements - Priority Order
|
|
3695
875
|
|
|
3696
|
-
|
|
876
|
+
1. `getByRole` - Best for accessibility
|
|
877
|
+
2. `getByLabelText` - For form fields
|
|
878
|
+
3. `getByText` - For non-interactive content
|
|
879
|
+
4. `getByTestId` - ⚠️ **LAST RESORT**
|
|
3697
880
|
|
|
3698
|
-
|
|
3699
|
-
|
|
881
|
+
```typescript
|
|
882
|
+
// ✅ Prefer
|
|
883
|
+
screen.getByRole("button", { name: /submit/i });
|
|
884
|
+
screen.getByLabelText("Email");
|
|
3700
885
|
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
├── Component.test.tsx
|
|
3705
|
-
├── utils.ts
|
|
3706
|
-
└── utils.test.ts
|
|
886
|
+
// ❌ Avoid
|
|
887
|
+
screen.getByClassName("user-card");
|
|
888
|
+
screen.getByTestId("element");
|
|
3707
889
|
```
|
|
3708
890
|
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
- Create test utilities in `testUtils.ts` or `test-utils.ts`
|
|
3712
|
-
- Reusable mocks in `mocks/` folder
|
|
891
|
+
## User Interactions
|
|
3713
892
|
|
|
3714
893
|
```typescript
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
title: "Test Shift",
|
|
3720
|
-
...overrides,
|
|
3721
|
-
};
|
|
3722
|
-
}
|
|
3723
|
-
```
|
|
3724
|
-
|
|
3725
|
-
## What to Test
|
|
3726
|
-
|
|
3727
|
-
### ✅ Do Test
|
|
3728
|
-
|
|
3729
|
-
- **Integration tests for features** - Multiple components working together
|
|
3730
|
-
- **Unit tests for helpers/utilities** - Pure business logic
|
|
3731
|
-
- **All states** - Loading, success, error
|
|
3732
|
-
- **User interactions** - Clicks, typing, form submissions
|
|
3733
|
-
- **Conditional rendering** - Different states/permissions
|
|
894
|
+
it("should handle form submission", async () => {
|
|
895
|
+
const user = userEvent.setup();
|
|
896
|
+
const onSubmit = vi.fn();
|
|
897
|
+
render(<Form onSubmit={onSubmit} />);
|
|
3734
898
|
|
|
3735
|
-
|
|
899
|
+
await user.type(screen.getByLabelText("Name"), "John");
|
|
900
|
+
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
3736
901
|
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
- **Styles/CSS** - Visual regression tests are separate
|
|
902
|
+
expect(onSubmit).toHaveBeenCalledWith({ name: "John" });
|
|
903
|
+
});
|
|
904
|
+
```
|
|
3741
905
|
|
|
3742
|
-
##
|
|
906
|
+
## Hook Testing
|
|
3743
907
|
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
908
|
+
```typescript
|
|
909
|
+
describe("useCustomHook", () => {
|
|
910
|
+
it("should return data after loading", async () => {
|
|
911
|
+
const { result } = renderHook(() => useCustomHook());
|
|
912
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
913
|
+
expect(result.current.data).toEqual(expectedData);
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
```
|
|
3748
917
|
|
|
3749
|
-
##
|
|
918
|
+
## MSW Pattern
|
|
3750
919
|
|
|
3751
|
-
|
|
920
|
+
**Always export factory functions, not static handlers:**
|
|
3752
921
|
|
|
3753
922
|
```typescript
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
923
|
+
// ✅ Good - flexible per test
|
|
924
|
+
export const createTestHandler = (data: Data[]) =>
|
|
925
|
+
rest.get(`/api/resource`, (req, res, ctx) => res(ctx.json(data)));
|
|
926
|
+
|
|
927
|
+
// Usage
|
|
928
|
+
mockServer.use(createTestHandler(customData));
|
|
3758
929
|
```
|
|
3759
930
|
|
|
3760
|
-
|
|
931
|
+
## Mocking
|
|
3761
932
|
|
|
3762
933
|
```typescript
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
934
|
+
vi.mock("@/lib/api", () => ({
|
|
935
|
+
get: vi.fn().mockResolvedValue({ data: { id: "1" } }),
|
|
936
|
+
}));
|
|
937
|
+
```
|
|
3766
938
|
|
|
3767
|
-
|
|
939
|
+
## What to Test
|
|
3768
940
|
|
|
3769
|
-
|
|
3770
|
-
});
|
|
3771
|
-
```
|
|
941
|
+
**✅ Do Test:**
|
|
3772
942
|
|
|
3773
|
-
|
|
943
|
+
- Integration tests for features
|
|
944
|
+
- Unit tests for utilities
|
|
945
|
+
- All states (loading, success, error)
|
|
946
|
+
- User interactions
|
|
3774
947
|
|
|
3775
|
-
|
|
3776
|
-
it("should show premium badge when user is premium", () => {
|
|
3777
|
-
render(<UserCard user={{ ...mockUser, isPremium: true }} />);
|
|
3778
|
-
expect(screen.getByText("Premium")).toBeInTheDocument();
|
|
3779
|
-
});
|
|
948
|
+
**❌ Don't Test:**
|
|
3780
949
|
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
});
|
|
3785
|
-
```
|
|
950
|
+
- Implementation details
|
|
951
|
+
- Third-party libraries
|
|
952
|
+
- Styles/CSS
|
|
3786
953
|
|
|
3787
954
|
<!-- Source: .ruler/frontend/typeScript.md -->
|
|
3788
955
|
|
|
@@ -3790,32 +957,21 @@ it("should not show premium badge when user is not premium", () => {
|
|
|
3790
957
|
|
|
3791
958
|
## Core Principles
|
|
3792
959
|
|
|
3793
|
-
- **Strict mode enabled** -
|
|
3794
|
-
- **Prefer type inference** -
|
|
960
|
+
- **Strict mode enabled** - Avoid `any`
|
|
961
|
+
- **Prefer type inference** - Let TypeScript infer when possible
|
|
3795
962
|
- **Explicit return types** for exported functions
|
|
3796
|
-
- **Zod for runtime validation** -
|
|
3797
|
-
- **No type assertions** unless unavoidable
|
|
963
|
+
- **Zod for runtime validation** - Single source of truth
|
|
964
|
+
- **No type assertions** unless unavoidable
|
|
3798
965
|
|
|
3799
966
|
## Type vs Interface
|
|
3800
967
|
|
|
3801
|
-
Use the right tool for the job:
|
|
3802
|
-
|
|
3803
|
-
### Use `interface` for:
|
|
3804
|
-
|
|
3805
|
-
- **Component props**
|
|
3806
|
-
- **Object shapes**
|
|
3807
|
-
- **Class definitions**
|
|
3808
|
-
- **Anything that might be extended**
|
|
3809
|
-
|
|
3810
968
|
```typescript
|
|
3811
|
-
// ✅
|
|
969
|
+
// ✅ Use interface for: props, object shapes, extensible types
|
|
3812
970
|
interface UserCardProps {
|
|
3813
971
|
user: User;
|
|
3814
972
|
onSelect: (id: string) => void;
|
|
3815
|
-
isHighlighted?: boolean;
|
|
3816
973
|
}
|
|
3817
974
|
|
|
3818
|
-
// ✅ Good - Interface can be extended
|
|
3819
975
|
interface BaseEntity {
|
|
3820
976
|
id: string;
|
|
3821
977
|
createdAt: string;
|
|
@@ -3823,88 +979,43 @@ interface BaseEntity {
|
|
|
3823
979
|
|
|
3824
980
|
interface User extends BaseEntity {
|
|
3825
981
|
name: string;
|
|
3826
|
-
email: string;
|
|
3827
982
|
}
|
|
3828
|
-
```
|
|
3829
|
-
|
|
3830
|
-
### Use `type` for:
|
|
3831
|
-
|
|
3832
|
-
- **Unions**
|
|
3833
|
-
- **Intersections**
|
|
3834
|
-
- **Tuples**
|
|
3835
|
-
- **Derived/conditional types**
|
|
3836
|
-
- **Type aliases**
|
|
3837
|
-
|
|
3838
|
-
```typescript
|
|
3839
|
-
// ✅ Good - Type for unions
|
|
3840
|
-
type Status = "pending" | "active" | "completed" | "failed";
|
|
3841
|
-
|
|
3842
|
-
// ✅ Good - Type for intersections
|
|
3843
|
-
type UserWithPermissions = User & { permissions: string[] };
|
|
3844
983
|
|
|
3845
|
-
// ✅
|
|
984
|
+
// ✅ Use type for: unions, intersections, tuples, derived types
|
|
985
|
+
type Status = "pending" | "active" | "completed";
|
|
986
|
+
type UserWithRoles = User & { roles: string[] };
|
|
3846
987
|
type Coordinate = [number, number];
|
|
3847
|
-
|
|
3848
|
-
// ✅ Good - Derived types
|
|
3849
988
|
type UserKeys = keyof User;
|
|
3850
|
-
type PartialUser = Partial<User>;
|
|
3851
989
|
```
|
|
3852
990
|
|
|
3853
991
|
## Naming Conventions
|
|
3854
992
|
|
|
3855
|
-
### Types and Interfaces
|
|
3856
|
-
|
|
3857
|
-
- **Suffix with purpose**: `Props`, `Response`, `Request`, `Options`, `Params`, `State`
|
|
3858
|
-
- **No `I` or `T` prefix** (we're not in C# or Java)
|
|
3859
|
-
- **PascalCase** for type names
|
|
3860
|
-
|
|
3861
993
|
```typescript
|
|
3862
|
-
// ✅
|
|
994
|
+
// ✅ Suffix with purpose (no I or T prefix)
|
|
3863
995
|
interface ButtonProps { ... }
|
|
3864
996
|
type ApiResponse = { ... };
|
|
3865
997
|
type UserOptions = { ... };
|
|
3866
998
|
|
|
3867
|
-
//
|
|
3868
|
-
interface IButton { ... } // No I prefix
|
|
3869
|
-
type TResponse = { ... }; // No T prefix
|
|
3870
|
-
interface buttonProps { ... } // Wrong case
|
|
3871
|
-
```
|
|
3872
|
-
|
|
3873
|
-
### Boolean Properties
|
|
3874
|
-
|
|
3875
|
-
Always prefix with `is`, `has`, `should`, `can`, `will`:
|
|
3876
|
-
|
|
3877
|
-
```typescript
|
|
3878
|
-
// ✅ Good
|
|
999
|
+
// ✅ Boolean properties
|
|
3879
1000
|
interface User {
|
|
3880
1001
|
isActive: boolean;
|
|
3881
1002
|
hasPermission: boolean;
|
|
3882
1003
|
shouldNotify: boolean;
|
|
3883
|
-
canEdit: boolean;
|
|
3884
|
-
willExpire: boolean;
|
|
3885
|
-
}
|
|
3886
|
-
|
|
3887
|
-
// ❌ Bad
|
|
3888
|
-
interface User {
|
|
3889
|
-
active: boolean; // Unclear
|
|
3890
|
-
permission: boolean; // Unclear
|
|
3891
|
-
notify: boolean; // Unclear
|
|
3892
1004
|
}
|
|
3893
1005
|
```
|
|
3894
1006
|
|
|
3895
1007
|
## Zod Integration
|
|
3896
1008
|
|
|
3897
1009
|
```typescript
|
|
3898
|
-
// Define schema
|
|
1010
|
+
// Define schema, infer type
|
|
3899
1011
|
const userSchema = z.object({
|
|
3900
1012
|
id: z.string(),
|
|
3901
1013
|
name: z.string(),
|
|
3902
1014
|
});
|
|
3903
1015
|
|
|
3904
|
-
// Infer type from schema
|
|
3905
1016
|
export type User = z.infer<typeof userSchema>;
|
|
3906
1017
|
|
|
3907
|
-
//
|
|
1018
|
+
// Validate API responses
|
|
3908
1019
|
const response = await get({
|
|
3909
1020
|
url: "/api/users",
|
|
3910
1021
|
responseSchema: userSchema,
|
|
@@ -3914,431 +1025,173 @@ const response = await get({
|
|
|
3914
1025
|
## Type Guards
|
|
3915
1026
|
|
|
3916
1027
|
```typescript
|
|
3917
|
-
export function
|
|
3918
|
-
return
|
|
1028
|
+
export function isUser(value: unknown): value is User {
|
|
1029
|
+
return userSchema.safeParse(value).success;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Usage
|
|
1033
|
+
if (isUser(data)) {
|
|
1034
|
+
console.log(data.name); // TypeScript knows data is User
|
|
3919
1035
|
}
|
|
3920
1036
|
```
|
|
3921
1037
|
|
|
3922
1038
|
## Constants
|
|
3923
1039
|
|
|
3924
1040
|
```typescript
|
|
3925
|
-
// Use const assertions
|
|
3926
|
-
export const
|
|
1041
|
+
// Use const assertions
|
|
1042
|
+
export const STATUSES = {
|
|
3927
1043
|
DRAFT: "draft",
|
|
3928
1044
|
PUBLISHED: "published",
|
|
3929
1045
|
} as const;
|
|
3930
1046
|
|
|
3931
|
-
export type
|
|
3932
|
-
```
|
|
3933
|
-
|
|
3934
|
-
## Function Types
|
|
3935
|
-
|
|
3936
|
-
```typescript
|
|
3937
|
-
// Explicit return types for exported functions
|
|
3938
|
-
export function calculateTotal(items: Item[]): number {
|
|
3939
|
-
return items.reduce((sum, item) => sum + item.price, 0);
|
|
3940
|
-
}
|
|
3941
|
-
|
|
3942
|
-
// Prefer interfaces for function props
|
|
3943
|
-
interface HandleSubmitProps {
|
|
3944
|
-
userId: string;
|
|
3945
|
-
data: FormData;
|
|
3946
|
-
}
|
|
3947
|
-
|
|
3948
|
-
export function handleSubmit(props: HandleSubmitProps): Promise<void> {
|
|
3949
|
-
// ...
|
|
3950
|
-
}
|
|
1047
|
+
export type Status = (typeof STATUSES)[keyof typeof STATUSES];
|
|
3951
1048
|
```
|
|
3952
1049
|
|
|
3953
1050
|
## Utility Types
|
|
3954
1051
|
|
|
3955
|
-
TypeScript provides powerful built-in utility types. Use them:
|
|
3956
|
-
|
|
3957
1052
|
```typescript
|
|
3958
|
-
//
|
|
1053
|
+
// Built-in utilities
|
|
3959
1054
|
type PartialUser = Partial<User>;
|
|
3960
|
-
|
|
3961
|
-
// Required - Make all properties required
|
|
3962
1055
|
type RequiredUser = Required<User>;
|
|
3963
|
-
|
|
3964
|
-
// Readonly - Make all properties readonly
|
|
3965
1056
|
type ReadonlyUser = Readonly<User>;
|
|
3966
|
-
|
|
3967
|
-
// Pick - Select specific properties
|
|
3968
1057
|
type UserIdOnly = Pick<User, "id" | "name">;
|
|
3969
|
-
|
|
3970
|
-
// Omit - Remove specific properties
|
|
3971
1058
|
type UserWithoutPassword = Omit<User, "password">;
|
|
3972
|
-
|
|
3973
|
-
// Record - Create object type with specific key/value types
|
|
3974
1059
|
type UserMap = Record<string, User>;
|
|
1060
|
+
type DefinedString = NonNullable<string | null>;
|
|
3975
1061
|
|
|
3976
|
-
//
|
|
3977
|
-
type DefinedString = NonNullable<string | null | undefined>; // string
|
|
3978
|
-
|
|
3979
|
-
// ReturnType - Extract return type from function
|
|
3980
|
-
function getUser() { return { id: '1', name: 'John' }; }
|
|
1062
|
+
// Function utilities
|
|
3981
1063
|
type GetUserResult = ReturnType<typeof getUser>;
|
|
3982
|
-
|
|
3983
|
-
// Parameters - Extract parameter types from function
|
|
3984
|
-
function updateUser(id: string, data: UserData) { ... }
|
|
3985
|
-
type UpdateUserParams = Parameters<typeof updateUser>; // [string, UserData]
|
|
1064
|
+
type UpdateUserParams = Parameters<typeof updateUser>;
|
|
3986
1065
|
```
|
|
3987
1066
|
|
|
3988
1067
|
## Avoiding `any`
|
|
3989
1068
|
|
|
3990
|
-
The `any` type defeats the purpose of TypeScript. Use alternatives:
|
|
3991
|
-
|
|
3992
1069
|
```typescript
|
|
3993
|
-
// ❌ Bad - Loses
|
|
3994
|
-
function process(data: any) {
|
|
3995
|
-
return data.value; // No error if value doesn't exist!
|
|
3996
|
-
}
|
|
1070
|
+
// ❌ Bad - Loses type safety
|
|
1071
|
+
function process(data: any) { ... }
|
|
3997
1072
|
|
|
3998
|
-
// ✅
|
|
1073
|
+
// ✅ Use unknown for truly unknown types
|
|
3999
1074
|
function process(data: unknown) {
|
|
4000
|
-
if (typeof data === "object" && data !== null
|
|
4001
|
-
|
|
1075
|
+
if (typeof data === "object" && data !== null) {
|
|
1076
|
+
// Type guard
|
|
4002
1077
|
}
|
|
4003
|
-
throw new Error("Invalid data");
|
|
4004
1078
|
}
|
|
4005
1079
|
|
|
4006
1080
|
// ✅ Better - Use generics
|
|
4007
1081
|
function process<T extends { value: string }>(data: T) {
|
|
4008
|
-
return data.value;
|
|
1082
|
+
return data.value;
|
|
4009
1083
|
}
|
|
4010
1084
|
|
|
4011
|
-
// ✅ Best - Use Zod
|
|
1085
|
+
// ✅ Best - Use Zod
|
|
4012
1086
|
const dataSchema = z.object({ value: z.string() });
|
|
4013
1087
|
function process(data: unknown) {
|
|
4014
|
-
const parsed = dataSchema.parse(data);
|
|
4015
|
-
return parsed.value;
|
|
1088
|
+
const parsed = dataSchema.parse(data);
|
|
1089
|
+
return parsed.value;
|
|
4016
1090
|
}
|
|
4017
1091
|
```
|
|
4018
1092
|
|
|
4019
1093
|
## Generics
|
|
4020
1094
|
|
|
4021
|
-
Use generics for reusable, type-safe code:
|
|
4022
|
-
|
|
4023
1095
|
```typescript
|
|
4024
|
-
//
|
|
1096
|
+
// Generic function
|
|
4025
1097
|
function getFirst<T>(array: T[]): T | undefined {
|
|
4026
1098
|
return array[0];
|
|
4027
1099
|
}
|
|
4028
1100
|
|
|
4029
|
-
|
|
4030
|
-
const firstName = getFirst(["a", "b"]); // string | undefined
|
|
4031
|
-
|
|
4032
|
-
// ✅ Good - Generic component
|
|
1101
|
+
// Generic component
|
|
4033
1102
|
interface ListProps<T> {
|
|
4034
1103
|
items: T[];
|
|
4035
1104
|
renderItem: (item: T) => ReactNode;
|
|
4036
|
-
keyExtractor: (item: T) => string;
|
|
4037
|
-
}
|
|
4038
|
-
|
|
4039
|
-
export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
|
|
4040
|
-
return (
|
|
4041
|
-
<Box>
|
|
4042
|
-
{items.map((item) => (
|
|
4043
|
-
<Box key={keyExtractor(item)}>{renderItem(item)}</Box>
|
|
4044
|
-
))}
|
|
4045
|
-
</Box>
|
|
4046
|
-
);
|
|
4047
1105
|
}
|
|
4048
1106
|
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
items={users}
|
|
4052
|
-
renderItem={(user) => <UserCard user={user} />} // user is User
|
|
4053
|
-
keyExtractor={(user) => user.id}
|
|
4054
|
-
/>;
|
|
4055
|
-
|
|
4056
|
-
// ✅ Good - Constrained generics
|
|
4057
|
-
interface HasId {
|
|
4058
|
-
id: string;
|
|
1107
|
+
export function List<T>({ items, renderItem }: ListProps<T>) {
|
|
1108
|
+
return <>{items.map((item) => renderItem(item))}</>;
|
|
4059
1109
|
}
|
|
4060
1110
|
|
|
4061
|
-
|
|
1111
|
+
// Constrained generics
|
|
1112
|
+
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
|
|
4062
1113
|
return items.find((item) => item.id === id);
|
|
4063
1114
|
}
|
|
4064
1115
|
```
|
|
4065
1116
|
|
|
4066
|
-
## Runtime Type Checking
|
|
4067
|
-
|
|
4068
|
-
Use type guards for runtime type checking:
|
|
4069
|
-
|
|
4070
|
-
```typescript
|
|
4071
|
-
// ✅ Good - Type predicate
|
|
4072
|
-
export function isUser(value: unknown): value is User {
|
|
4073
|
-
return (
|
|
4074
|
-
typeof value === "object" &&
|
|
4075
|
-
value !== null &&
|
|
4076
|
-
"id" in value &&
|
|
4077
|
-
"name" in value &&
|
|
4078
|
-
typeof value.id === "string" &&
|
|
4079
|
-
typeof value.name === "string"
|
|
4080
|
-
);
|
|
4081
|
-
}
|
|
4082
|
-
|
|
4083
|
-
// Usage
|
|
4084
|
-
function processData(data: unknown) {
|
|
4085
|
-
if (isUser(data)) {
|
|
4086
|
-
console.log(data.name); // TypeScript knows data is User
|
|
4087
|
-
}
|
|
4088
|
-
}
|
|
4089
|
-
|
|
4090
|
-
// ✅ Better - Use Zod (runtime validation + type guard)
|
|
4091
|
-
const userSchema = z.object({
|
|
4092
|
-
id: z.string(),
|
|
4093
|
-
name: z.string(),
|
|
4094
|
-
});
|
|
4095
|
-
|
|
4096
|
-
export function isUser(value: unknown): value is User {
|
|
4097
|
-
return userSchema.safeParse(value).success;
|
|
4098
|
-
}
|
|
4099
|
-
|
|
4100
|
-
// ✅ Good - Discriminated unions
|
|
4101
|
-
type ApiResponse =
|
|
4102
|
-
| { status: "success"; data: User }
|
|
4103
|
-
| { status: "error"; error: string }
|
|
4104
|
-
| { status: "loading" };
|
|
4105
|
-
|
|
4106
|
-
function handleResponse(response: ApiResponse) {
|
|
4107
|
-
switch (response.status) {
|
|
4108
|
-
case "success":
|
|
4109
|
-
console.log(response.data); // TypeScript knows data exists
|
|
4110
|
-
break;
|
|
4111
|
-
case "error":
|
|
4112
|
-
console.log(response.error); // TypeScript knows error exists
|
|
4113
|
-
break;
|
|
4114
|
-
case "loading":
|
|
4115
|
-
// TypeScript knows no data or error exists
|
|
4116
|
-
break;
|
|
4117
|
-
}
|
|
4118
|
-
}
|
|
4119
|
-
```
|
|
4120
|
-
|
|
4121
|
-
## Const Assertions
|
|
4122
|
-
|
|
4123
|
-
Use `as const` for readonly literal types:
|
|
4124
|
-
|
|
4125
|
-
```typescript
|
|
4126
|
-
// ✅ Good - Const assertion for object
|
|
4127
|
-
export const STATUSES = {
|
|
4128
|
-
PENDING: "pending",
|
|
4129
|
-
ACTIVE: "active",
|
|
4130
|
-
COMPLETED: "completed",
|
|
4131
|
-
} as const;
|
|
4132
|
-
|
|
4133
|
-
// Type: 'pending' | 'active' | 'completed'
|
|
4134
|
-
export type Status = (typeof STATUSES)[keyof typeof STATUSES];
|
|
4135
|
-
|
|
4136
|
-
// ✅ Good - Const assertion for array
|
|
4137
|
-
export const COLORS = ["red", "blue", "green"] as const;
|
|
4138
|
-
export type Color = (typeof COLORS)[number]; // 'red' | 'blue' | 'green'
|
|
4139
|
-
|
|
4140
|
-
// ❌ Bad - Without const assertion
|
|
4141
|
-
export const STATUSES = {
|
|
4142
|
-
PENDING: "pending", // Type: string (too loose!)
|
|
4143
|
-
ACTIVE: "active",
|
|
4144
|
-
};
|
|
4145
|
-
```
|
|
4146
|
-
|
|
4147
1117
|
## Discriminated Unions
|
|
4148
1118
|
|
|
4149
|
-
Use for state machines and API responses:
|
|
4150
|
-
|
|
4151
1119
|
```typescript
|
|
4152
|
-
//
|
|
1120
|
+
// State machine
|
|
4153
1121
|
type QueryState<T> =
|
|
4154
1122
|
| { status: "idle" }
|
|
4155
1123
|
| { status: "loading" }
|
|
4156
1124
|
| { status: "success"; data: T }
|
|
4157
1125
|
| { status: "error"; error: Error };
|
|
4158
1126
|
|
|
4159
|
-
|
|
4160
|
-
// ...
|
|
4161
|
-
}
|
|
4162
|
-
|
|
4163
|
-
// Usage - Type narrowing works automatically
|
|
4164
|
-
const state = useQueryState<User>();
|
|
4165
|
-
|
|
1127
|
+
// Automatic type narrowing
|
|
4166
1128
|
if (state.status === "success") {
|
|
4167
1129
|
console.log(state.data); // TypeScript knows data exists
|
|
4168
1130
|
}
|
|
4169
1131
|
|
|
4170
|
-
//
|
|
4171
|
-
type Action =
|
|
4172
|
-
| { type: "SET_USER"; payload: User }
|
|
4173
|
-
| { type: "CLEAR_USER" }
|
|
4174
|
-
| { type: "UPDATE_USER"; payload: Partial<User> };
|
|
1132
|
+
// Actions
|
|
1133
|
+
type Action = { type: "SET_USER"; payload: User } | { type: "CLEAR_USER" };
|
|
4175
1134
|
|
|
4176
1135
|
function reducer(state: State, action: Action) {
|
|
4177
1136
|
switch (action.type) {
|
|
4178
1137
|
case "SET_USER":
|
|
4179
|
-
return { ...state, user: action.payload };
|
|
1138
|
+
return { ...state, user: action.payload };
|
|
4180
1139
|
case "CLEAR_USER":
|
|
4181
|
-
return { ...state, user: null };
|
|
4182
|
-
case "UPDATE_USER":
|
|
4183
|
-
return { ...state, user: { ...state.user, ...action.payload } };
|
|
1140
|
+
return { ...state, user: null };
|
|
4184
1141
|
}
|
|
4185
1142
|
}
|
|
4186
1143
|
```
|
|
4187
1144
|
|
|
4188
|
-
## Function Overloads
|
|
4189
|
-
|
|
4190
|
-
Use for functions with different parameter/return combinations:
|
|
4191
|
-
|
|
4192
|
-
```typescript
|
|
4193
|
-
// ✅ Good - Function overloads
|
|
4194
|
-
function formatValue(value: string): string;
|
|
4195
|
-
function formatValue(value: number): string;
|
|
4196
|
-
function formatValue(value: Date): string;
|
|
4197
|
-
function formatValue(value: string | number | Date): string {
|
|
4198
|
-
if (typeof value === "string") return value;
|
|
4199
|
-
if (typeof value === "number") return value.toString();
|
|
4200
|
-
return value.toISOString();
|
|
4201
|
-
}
|
|
4202
|
-
|
|
4203
|
-
// TypeScript knows the return type based on input
|
|
4204
|
-
const str1 = formatValue("hello"); // string
|
|
4205
|
-
const str2 = formatValue(123); // string
|
|
4206
|
-
const str3 = formatValue(new Date()); // string
|
|
4207
|
-
```
|
|
4208
|
-
|
|
4209
|
-
## Template Literal Types
|
|
4210
|
-
|
|
4211
|
-
Use for type-safe string patterns:
|
|
4212
|
-
|
|
4213
|
-
```typescript
|
|
4214
|
-
// ✅ Good - Event handler props with enforced naming
|
|
4215
|
-
type EventHandlers<T extends string> = {
|
|
4216
|
-
[K in T as `on${Capitalize<K>}`]: () => void;
|
|
4217
|
-
};
|
|
4218
|
-
|
|
4219
|
-
type Props = EventHandlers<"click" | "hover" | "submit">;
|
|
4220
|
-
// Results in: { onClick: () => void; onHover: () => void; onSubmit: () => void; }
|
|
4221
|
-
|
|
4222
|
-
// ✅ Good - Validate event handler keys
|
|
4223
|
-
type ValidateEventKey<K extends string> = K extends `on${Capitalize<string>}` ? K : never;
|
|
4224
|
-
|
|
4225
|
-
interface ComponentProps {
|
|
4226
|
-
onClick: () => void; // ✅ Valid
|
|
4227
|
-
onHover: () => void; // ✅ Valid
|
|
4228
|
-
// click: () => void; // ❌ Does not satisfy ValidateEventKey
|
|
4229
|
-
}
|
|
4230
|
-
|
|
4231
|
-
// ✅ Good - Route paths
|
|
4232
|
-
type Route = `/users/${string}` | `/posts/${string}` | "/";
|
|
4233
|
-
|
|
4234
|
-
const validRoute: Route = "/users/123"; // ✅
|
|
4235
|
-
const invalidRoute: Route = "users/123"; // ❌ Error
|
|
4236
|
-
|
|
4237
|
-
// ✅ Good - CSS properties
|
|
4238
|
-
type CSSProperty = `${"margin" | "padding"}${"Top" | "Bottom" | "Left" | "Right"}`;
|
|
4239
|
-
// 'marginTop' | 'marginBottom' | 'marginLeft' | 'marginRight' | 'paddingTop' | ...
|
|
4240
|
-
```
|
|
4241
|
-
|
|
4242
|
-
## Mapped Types
|
|
4243
|
-
|
|
4244
|
-
Create new types by transforming existing ones:
|
|
4245
|
-
|
|
4246
|
-
```typescript
|
|
4247
|
-
// ✅ Good - Make all properties optional
|
|
4248
|
-
type Optional<T> = {
|
|
4249
|
-
[K in keyof T]?: T[K];
|
|
4250
|
-
};
|
|
4251
|
-
|
|
4252
|
-
// ✅ Good - Make all properties readonly
|
|
4253
|
-
type Immutable<T> = {
|
|
4254
|
-
readonly [K in keyof T]: T[K];
|
|
4255
|
-
};
|
|
4256
|
-
|
|
4257
|
-
// ✅ Good - Add suffix to all keys
|
|
4258
|
-
type Prefixed<T, P extends string> = {
|
|
4259
|
-
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
|
|
4260
|
-
};
|
|
4261
|
-
|
|
4262
|
-
type User = { name: string; age: number };
|
|
4263
|
-
type PrefixedUser = Prefixed<User, "user">;
|
|
4264
|
-
// { userName: string; userAge: number }
|
|
4265
|
-
```
|
|
4266
|
-
|
|
4267
1145
|
## Type Narrowing
|
|
4268
1146
|
|
|
4269
|
-
Let TypeScript narrow types automatically:
|
|
4270
|
-
|
|
4271
1147
|
```typescript
|
|
4272
|
-
//
|
|
1148
|
+
// typeof
|
|
4273
1149
|
function format(value: string | number) {
|
|
4274
1150
|
if (typeof value === "string") {
|
|
4275
|
-
return value.toUpperCase();
|
|
4276
|
-
}
|
|
4277
|
-
return value.toFixed(2); // TypeScript knows value is number
|
|
4278
|
-
}
|
|
4279
|
-
|
|
4280
|
-
// ✅ Good - Truthiness narrowing
|
|
4281
|
-
function getLength(value: string | null) {
|
|
4282
|
-
if (value) {
|
|
4283
|
-
return value.length; // TypeScript knows value is string
|
|
1151
|
+
return value.toUpperCase();
|
|
4284
1152
|
}
|
|
4285
|
-
return
|
|
4286
|
-
}
|
|
4287
|
-
|
|
4288
|
-
// ✅ Good - In narrowing
|
|
4289
|
-
interface Fish {
|
|
4290
|
-
swim: () => void;
|
|
4291
|
-
}
|
|
4292
|
-
interface Bird {
|
|
4293
|
-
fly: () => void;
|
|
1153
|
+
return value.toFixed(2);
|
|
4294
1154
|
}
|
|
4295
1155
|
|
|
1156
|
+
// in operator
|
|
4296
1157
|
function move(animal: Fish | Bird) {
|
|
4297
1158
|
if ("swim" in animal) {
|
|
4298
|
-
animal.swim();
|
|
1159
|
+
animal.swim();
|
|
4299
1160
|
} else {
|
|
4300
|
-
animal.fly();
|
|
1161
|
+
animal.fly();
|
|
4301
1162
|
}
|
|
4302
1163
|
}
|
|
4303
1164
|
|
|
4304
|
-
//
|
|
1165
|
+
// instanceof
|
|
4305
1166
|
function handleError(error: unknown) {
|
|
4306
1167
|
if (error instanceof Error) {
|
|
4307
|
-
console.log(error.message);
|
|
1168
|
+
console.log(error.message);
|
|
4308
1169
|
}
|
|
4309
1170
|
}
|
|
4310
1171
|
```
|
|
4311
1172
|
|
|
4312
1173
|
## Common Patterns
|
|
4313
1174
|
|
|
4314
|
-
### Optional Chaining & Nullish Coalescing
|
|
4315
|
-
|
|
4316
1175
|
```typescript
|
|
4317
|
-
//
|
|
1176
|
+
// Optional chaining
|
|
4318
1177
|
const userName = user?.profile?.name;
|
|
4319
1178
|
|
|
4320
|
-
//
|
|
1179
|
+
// Nullish coalescing
|
|
4321
1180
|
const displayName = userName ?? "Anonymous";
|
|
4322
1181
|
|
|
4323
|
-
//
|
|
4324
|
-
const
|
|
4325
|
-
```
|
|
4326
|
-
|
|
4327
|
-
### Non-null Assertion (Use Sparingly)
|
|
1182
|
+
// Non-null assertion (use sparingly)
|
|
1183
|
+
const user = getUser()!;
|
|
4328
1184
|
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
// ✅ Better - Handle null case
|
|
4334
|
-
const user = getUser();
|
|
4335
|
-
if (!user) throw new Error("User not found");
|
|
4336
|
-
// Now TypeScript knows user exists
|
|
1185
|
+
// Template literal types
|
|
1186
|
+
type Route = `/users/${string}` | `/posts/${string}`;
|
|
1187
|
+
type EventHandler = `on${Capitalize<string>}`;
|
|
4337
1188
|
```
|
|
4338
1189
|
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
# UI and Styling
|
|
1190
|
+
## Best Practices
|
|
4342
1191
|
|
|
4343
|
-
-
|
|
4344
|
-
-
|
|
1192
|
+
- **Never use `any`** - Use `unknown` or generics
|
|
1193
|
+
- **Discriminated unions** for state machines
|
|
1194
|
+
- **Zod** for runtime validation
|
|
1195
|
+
- **Type guards** over type assertions
|
|
1196
|
+
- **Const assertions** for readonly values
|
|
1197
|
+
- **Utility types** to transform existing types
|