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