@clipboard-health/ai-rules 1.6.44 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/AGENTS.md +659 -258
- package/common/AGENTS.md +250 -46
- package/frontend/AGENTS.md +481 -791
- package/fullstack/AGENTS.md +838 -951
- package/package.json +1 -1
package/frontend/AGENTS.md
CHANGED
|
@@ -1,1039 +1,729 @@
|
|
|
1
1
|
<!-- Generated by Ruler -->
|
|
2
2
|
|
|
3
|
-
<!-- Source: .ruler/common/
|
|
3
|
+
<!-- Source: .ruler/common/commitMessages.md -->
|
|
4
4
|
|
|
5
|
-
#
|
|
5
|
+
# Commit Messages
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Use functional and declarative programming patterns.
|
|
9
|
-
- Prefer iteration and modularization over code duplication.
|
|
10
|
-
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
|
|
11
|
-
- Structure files: constants, types, exported functions, non-exported functions.
|
|
12
|
-
- Avoid magic strings and numbers; define constants.
|
|
13
|
-
- Use camelCase for files and directories (e.g., modules/shiftOffers.ts).
|
|
14
|
-
- When declaring functions, use the `function` keyword, not `const`.
|
|
15
|
-
- Files should read from top to bottom: `export`ed items live on top and the internal functions and methods they call go below them.
|
|
16
|
-
- Prefer data immutability.
|
|
7
|
+
Follow Conventional Commits 1.0 spec for commit messages and PR titles.
|
|
17
8
|
|
|
18
|
-
|
|
9
|
+
<!-- Source: .ruler/common/configuration.md -->
|
|
19
10
|
|
|
20
|
-
|
|
11
|
+
# Configuration
|
|
21
12
|
|
|
22
|
-
|
|
13
|
+
```text
|
|
14
|
+
Contains secrets?
|
|
15
|
+
└── Yes → SSM Parameter Store
|
|
16
|
+
└── No → Engineers-only, tolerate 1hr propagation?
|
|
17
|
+
└── Yes → Hardcode with @clipboard-health/config
|
|
18
|
+
└── No → 1:1 with DB entity OR needs custom UI?
|
|
19
|
+
└── Yes → Database
|
|
20
|
+
└── No → LaunchDarkly feature flag
|
|
21
|
+
```
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
<!-- Source: .ruler/common/featureFlags.md -->
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
- Handle errors and edge cases at the beginning of functions.
|
|
28
|
-
- Use early returns for error conditions to avoid deeply nested if statements.
|
|
29
|
-
- Place the happy path last in the function for improved readability.
|
|
30
|
-
- Avoid unnecessary else statements; use the if-return pattern instead.
|
|
31
|
-
- Use guard clauses to handle preconditions and invalid states early.
|
|
32
|
-
- Implement proper error logging and user-friendly error messages.
|
|
33
|
-
- Favor `@clipboard-health/util-ts`'s `Either` type for expected errors instead of `try`/`catch`.
|
|
25
|
+
# Feature Flags
|
|
34
26
|
|
|
35
|
-
|
|
27
|
+
**Naming:** `YYYY-MM-[kind]-[subject]` (e.g., `2024-03-release-new-booking-flow`)
|
|
36
28
|
|
|
37
|
-
|
|
29
|
+
| Kind | Purpose |
|
|
30
|
+
| ------------ | ------------------------ |
|
|
31
|
+
| `release` | Gradual rollout to 100% |
|
|
32
|
+
| `enable` | Kill switch |
|
|
33
|
+
| `experiment` | Trial for small audience |
|
|
34
|
+
| `configure` | Runtime config |
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
- Name test variables using the `mockX`, `input`, `expected`, `actual` convention.
|
|
41
|
-
- Aim for high test coverage, writing both positive and negative test cases.
|
|
42
|
-
- Prefer `it.each` for multiple test cases.
|
|
43
|
-
- Avoid conditional logic in tests.
|
|
36
|
+
**Rules:**
|
|
44
37
|
|
|
45
|
-
|
|
38
|
+
- "Off" = default/safer value
|
|
39
|
+
- No permanent flags (except `configure`)
|
|
40
|
+
- Create archival ticket when creating flag
|
|
41
|
+
- Validate staging before production
|
|
42
|
+
- Always provide default values in code
|
|
43
|
+
- Clean up after full launch
|
|
46
44
|
|
|
47
|
-
|
|
45
|
+
<!-- Source: .ruler/common/loggingObservability.md -->
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
- Avoid enums; use const maps instead.
|
|
51
|
-
- Strive for precise types. Look for type definitions in the codebase and create your own if none exist.
|
|
52
|
-
- Avoid using type assertions like `as` or `!` unless absolutely necessary.
|
|
53
|
-
- Use the `unknown` type instead of `any` when the type is truly unknown.
|
|
54
|
-
- Use an object to pass multiple function params and to return results.
|
|
55
|
-
- Leverage union types, intersection types, and conditional types for complex type definitions.
|
|
56
|
-
- Use mapped types and utility types (e.g., `Partial<T>`, `Pick<T>`, `Omit<T>`) to transform existing types.
|
|
57
|
-
- Implement generic types to create reusable, flexible type definitions.
|
|
58
|
-
- Utilize the `keyof` operator and index access types for dynamic property access.
|
|
59
|
-
- Implement discriminated unions for type-safe handling of different object shapes where appropriate.
|
|
60
|
-
- Use the `infer` keyword in conditional types for type inference.
|
|
61
|
-
- Leverage `readonly` properties for function parameter immutability.
|
|
62
|
-
- Prefer narrow types whenever possible with `as const` assertions, `typeof`, `instanceof`, `satisfies`, and custom type guards.
|
|
63
|
-
- Implement exhaustiveness checking using `never`.
|
|
47
|
+
# Logging & Observability
|
|
64
48
|
|
|
65
|
-
|
|
49
|
+
## Log Levels
|
|
66
50
|
|
|
67
|
-
|
|
51
|
+
| Level | When |
|
|
52
|
+
| ----- | ------------------------------------------ |
|
|
53
|
+
| ERROR | Required functionality broken (2am pager?) |
|
|
54
|
+
| WARN | Optional broken OR recovered from failure |
|
|
55
|
+
| INFO | Informative, ignorable during normal ops |
|
|
56
|
+
| DEBUG | Local only, not production |
|
|
68
57
|
|
|
69
|
-
##
|
|
58
|
+
## Best Practices
|
|
70
59
|
|
|
71
60
|
```typescript
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const handleAction = useCallback(async () => { /* ... */ }, []);
|
|
61
|
+
// Bad
|
|
62
|
+
logger.error("Operation failed");
|
|
63
|
+
logger.error(`Operation failed for workplace ${workplaceId}`);
|
|
76
64
|
|
|
77
|
-
|
|
78
|
-
|
|
65
|
+
// Good—structured context
|
|
66
|
+
logger.error("Exporting urgent shifts to CSV failed", {
|
|
67
|
+
workplaceId,
|
|
68
|
+
startDate,
|
|
69
|
+
endDate,
|
|
70
|
+
});
|
|
79
71
|
```
|
|
80
72
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
- **Always** prefix with `use`
|
|
84
|
-
- Boolean: Use `useIs*`, `useHas*`, or `useCan*` (e.g., `useIsEnabled`, `useHasPermission`, `useCanEdit`)
|
|
85
|
-
- Data: `useGetUser`, `useFeatureData`
|
|
86
|
-
- Actions: `useSubmitForm`
|
|
73
|
+
**Never log:** PII, PHI, tokens, secrets, SSN, account numbers, entire request/response/headers.
|
|
87
74
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
- Return objects (not arrays) for complex hooks
|
|
91
|
-
- Name clearly: `isLoading` not `loading`
|
|
92
|
-
|
|
93
|
-
## State Management with Constate
|
|
94
|
-
|
|
95
|
-
Use `constate` for shared state between components:
|
|
75
|
+
Use metrics for counting:
|
|
96
76
|
|
|
97
77
|
```typescript
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
function useFilters() {
|
|
101
|
-
const [filters, setFilters] = useState<Filters>({});
|
|
102
|
-
return { filters, setFilters };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export const [FiltersProvider, useFiltersContext] = constate(useFilters);
|
|
78
|
+
datadogMetrics.increment("negotiation.errors", { state: "New York" });
|
|
106
79
|
```
|
|
107
80
|
|
|
108
|
-
|
|
109
|
-
**When NOT:** Server state (use React Query), simple parent-child (use props)
|
|
81
|
+
<!-- Source: .ruler/common/pullRequests.md -->
|
|
110
82
|
|
|
111
|
-
|
|
83
|
+
# Pull Requests
|
|
112
84
|
|
|
113
|
-
|
|
114
|
-
// Boolean hooks
|
|
115
|
-
export function useIsFeatureEnabled(): boolean {
|
|
116
|
-
return useFeatureFlags().includes("feature");
|
|
117
|
-
}
|
|
85
|
+
**Requirements:**
|
|
118
86
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
87
|
+
1. Clear title: change summary + ticket; Conventional Commits 1.0
|
|
88
|
+
2. Thorough description: why, not just what
|
|
89
|
+
3. Small & focused: single concept
|
|
90
|
+
4. Tested: service tests + validation proof
|
|
91
|
+
5. Passing CI
|
|
125
92
|
|
|
126
|
-
|
|
127
|
-
export function useBookingsData() {
|
|
128
|
-
const shifts = useGetShifts();
|
|
129
|
-
const invites = useGetInvites();
|
|
93
|
+
**Description:** Link ticket, context, reasoning, areas of concern.
|
|
130
94
|
|
|
131
|
-
|
|
132
|
-
() => [...(shifts.data ?? []), ...(invites.data ?? [])],
|
|
133
|
-
[shifts.data, invites.data],
|
|
134
|
-
);
|
|
95
|
+
<!-- Source: .ruler/common/security.md -->
|
|
135
96
|
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
```
|
|
97
|
+
# Security
|
|
139
98
|
|
|
140
|
-
|
|
99
|
+
**Secrets:**
|
|
141
100
|
|
|
142
|
-
-
|
|
143
|
-
-
|
|
144
|
-
-
|
|
145
|
-
- Return stable references with `useCallback`/`useMemo`
|
|
101
|
+
- `.env` locally (gitignored)
|
|
102
|
+
- Production: AWS SSM Parameter Store
|
|
103
|
+
- Prefer short-lived tokens
|
|
146
104
|
|
|
147
|
-
|
|
105
|
+
**Naming:** `[ENV]_[VENDOR]_[TYPE]_usedBy_[CLIENT]_[SCOPE]_[CREATED_AT]_[OWNER]`
|
|
148
106
|
|
|
149
|
-
|
|
107
|
+
<!-- Source: .ruler/common/structuredConcurrency.md -->
|
|
150
108
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
- **React Query** (@tanstack/react-query) for all API calls
|
|
154
|
-
- **Zod** for response validation
|
|
155
|
-
|
|
156
|
-
## Core Principles
|
|
157
|
-
|
|
158
|
-
1. **Use URL and query parameters in query keys** - Makes cache invalidation predictable
|
|
159
|
-
2. **Define Zod schemas** for all API requests/responses
|
|
160
|
-
3. **Rely on React Query state** - Use `isLoading`, `isError`, `isSuccess`
|
|
161
|
-
4. **Use `enabled` for conditional fetching**
|
|
162
|
-
5. **Use `invalidateQueries` for disabled queries** - Not `refetch()` which ignores enabled state
|
|
163
|
-
|
|
164
|
-
## Hook Patterns
|
|
109
|
+
# Structured Concurrency
|
|
165
110
|
|
|
166
111
|
```typescript
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Paginated query
|
|
178
|
-
export function usePaginatedItems(params: Params) {
|
|
179
|
-
return useInfiniteQuery({
|
|
180
|
-
queryKey: ["items", params],
|
|
181
|
-
queryFn: ({ pageParam }) => api.get("/items", { cursor: pageParam, ...params }),
|
|
182
|
-
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
183
|
-
});
|
|
112
|
+
// Cancellation propagation
|
|
113
|
+
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
116
|
+
try {
|
|
117
|
+
return await fetch(url, { signal: controller.signal });
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(timeoutId);
|
|
120
|
+
}
|
|
184
121
|
}
|
|
185
122
|
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
mutationFn: (data: CreateItemRequest) => api.post("/items", data),
|
|
191
|
-
onSuccess: () => queryClient.invalidateQueries(["items"]),
|
|
192
|
-
});
|
|
193
|
-
}
|
|
123
|
+
// All results regardless of failures
|
|
124
|
+
const results = await Promise.allSettled(operations);
|
|
125
|
+
const succeeded = results.filter((r) => r.status === "fulfilled");
|
|
126
|
+
const failed = results.filter((r) => r.status === "rejected");
|
|
194
127
|
```
|
|
195
128
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
For detailed error handling strategies, see `error-handling.md`.
|
|
129
|
+
<!-- Source: .ruler/common/testing.md -->
|
|
199
130
|
|
|
200
|
-
|
|
201
|
-
useQuery({
|
|
202
|
-
queryKey: ["resource"],
|
|
203
|
-
queryFn: fetchResource,
|
|
204
|
-
useErrorBoundary: (error) => {
|
|
205
|
-
// Show error boundary for 500s, not 404s
|
|
206
|
-
return !(axios.isAxiosError(error) && error.response?.status === 404);
|
|
207
|
-
},
|
|
208
|
-
retry: (failureCount, error) => {
|
|
209
|
-
// Don't retry 4xx errors
|
|
210
|
-
if (axios.isAxiosError(error) && error.response?.status < 500) return false;
|
|
211
|
-
return failureCount < 3;
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
```
|
|
131
|
+
# Testing
|
|
215
132
|
|
|
216
|
-
##
|
|
133
|
+
## Unit Tests
|
|
217
134
|
|
|
218
|
-
|
|
219
|
-
// Include URL and params for predictable cache invalidation
|
|
220
|
-
export const queryKeys = {
|
|
221
|
-
users: ["users"] as const,
|
|
222
|
-
user: (id: string) => ["users", id] as const,
|
|
223
|
-
userPosts: (id: string) => ["users", id, "posts"] as const,
|
|
224
|
-
};
|
|
225
|
-
```
|
|
135
|
+
Use when: error handling hard to trigger black-box, concurrency scenarios, >5 variations, pure function logic.
|
|
226
136
|
|
|
227
|
-
##
|
|
137
|
+
## Conventions
|
|
228
138
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
});
|
|
235
|
-
```
|
|
139
|
+
- Use `it` not `test`; `describe` for grouping
|
|
140
|
+
- Arrange-Act-Assert with newlines between
|
|
141
|
+
- Variables: `mockX`, `input`, `expected`, `actual`
|
|
142
|
+
- Prefer `it.each` for multiple cases
|
|
143
|
+
- No conditional logic in tests
|
|
236
144
|
|
|
237
|
-
|
|
145
|
+
<!-- Source: .ruler/common/typeScript.md -->
|
|
238
146
|
|
|
239
|
-
|
|
240
|
-
- `usePaginatedX` - Infinite queries
|
|
241
|
-
- `useCreateX`, `useUpdateX`, `useDeleteX` - Mutations
|
|
147
|
+
# TypeScript
|
|
242
148
|
|
|
243
|
-
##
|
|
149
|
+
## Naming Conventions
|
|
244
150
|
|
|
245
|
-
|
|
151
|
+
| Element | Convention | Example |
|
|
152
|
+
| --------------------- | --------------------- | ---------------------------- |
|
|
153
|
+
| File-scope constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
|
|
154
|
+
| Acronyms in camelCase | Lowercase after first | `httpRequest`, `gpsPosition` |
|
|
155
|
+
| Files | Singular, dotted | `user.service.ts` |
|
|
246
156
|
|
|
247
|
-
|
|
157
|
+
## Core Rules
|
|
248
158
|
|
|
249
|
-
|
|
159
|
+
- Strict-mode TypeScript; prefer interfaces over types
|
|
160
|
+
- Avoid enums—use const maps
|
|
161
|
+
- NEVER use `any`—use `unknown` or generics
|
|
162
|
+
- Avoid type assertions (`as`, `!`) unless absolutely necessary
|
|
163
|
+
- Use `function` keyword for declarations, not `const`
|
|
164
|
+
- Prefer `undefined` over `null`
|
|
165
|
+
- Explicit return types on functions
|
|
166
|
+
- Files read top-to-bottom: exports first, internal helpers below
|
|
167
|
+
- Boolean props: `is*`, `has*`, `should*`, `can*`
|
|
168
|
+
- Use const assertions for constants: `as const`
|
|
250
169
|
|
|
251
|
-
##
|
|
170
|
+
## Types
|
|
252
171
|
|
|
253
172
|
```typescript
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
useErrorBoundary: (error) => {
|
|
258
|
-
// Show error boundary for 500s, not 404s
|
|
259
|
-
return !(axios.isAxiosError(error) && error.response?.status === 404);
|
|
260
|
-
},
|
|
261
|
-
retry: (failureCount, error) => {
|
|
262
|
-
// Don't retry 4xx errors
|
|
263
|
-
if (axios.isAxiosError(error) && error.response?.status < 500) return false;
|
|
264
|
-
return failureCount < 3;
|
|
265
|
-
},
|
|
266
|
-
});
|
|
267
|
-
```
|
|
173
|
+
// Strong typing
|
|
174
|
+
function process(arg: unknown) {} // Better than any
|
|
175
|
+
function process<T>(arg: T) {} // Best
|
|
268
176
|
|
|
269
|
-
|
|
177
|
+
// Nullable checks
|
|
178
|
+
if (foo == null) {
|
|
179
|
+
} // Clear intent
|
|
180
|
+
if (isDefined(foo)) {
|
|
181
|
+
} // Better with utility
|
|
270
182
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
183
|
+
// Quantity values—always unambiguous
|
|
184
|
+
const money = { amountInMinorUnits: 500, currencyCode: "USD" };
|
|
185
|
+
const durationMinutes = 30;
|
|
186
|
+
```
|
|
274
187
|
|
|
275
|
-
|
|
276
|
-
if (isError) return <ErrorState onRetry={refetch} />;
|
|
188
|
+
**Type Techniques:**
|
|
277
189
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
190
|
+
- Union, intersection, conditional types for complex definitions
|
|
191
|
+
- Mapped types: `Partial<T>`, `Pick<T>`, `Omit<T>`
|
|
192
|
+
- `keyof`, index access types, discriminated unions
|
|
193
|
+
- `as const`, `typeof`, `instanceof`, `satisfies`, type guards
|
|
194
|
+
- Exhaustiveness checking with `never`
|
|
195
|
+
- `readonly` for parameter immutability
|
|
281
196
|
|
|
282
|
-
##
|
|
197
|
+
## Functions
|
|
283
198
|
|
|
284
199
|
```typescript
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
queryClient.invalidateQueries(["documents"]);
|
|
290
|
-
showSuccessToast("Document created");
|
|
291
|
-
},
|
|
292
|
-
onError: (error) => {
|
|
293
|
-
logError("CREATE_DOCUMENT_FAILURE", error);
|
|
294
|
-
showErrorToast("Failed to create document");
|
|
295
|
-
},
|
|
296
|
-
});
|
|
200
|
+
// Object arguments with interfaces
|
|
201
|
+
interface CreateUserRequest {
|
|
202
|
+
email: string;
|
|
203
|
+
name?: string;
|
|
297
204
|
}
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
## Validation Errors
|
|
301
205
|
|
|
302
|
-
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
age: z.number().min(18, "Must be 18+"),
|
|
307
|
-
});
|
|
206
|
+
function createUser(request: CreateUserRequest): User {
|
|
207
|
+
const { email, name } = request; // Destructure inside
|
|
208
|
+
// ...
|
|
209
|
+
}
|
|
308
210
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
error.errors.forEach((err) => showFieldError(err.path.join("."), err.message));
|
|
211
|
+
// Guard clauses for early returns
|
|
212
|
+
function processOrder(order: Order): Result {
|
|
213
|
+
if (!order.isValid) {
|
|
214
|
+
return { error: "Invalid order" };
|
|
314
215
|
}
|
|
216
|
+
// Main logic
|
|
315
217
|
}
|
|
316
218
|
```
|
|
317
219
|
|
|
318
|
-
##
|
|
220
|
+
## Objects & Arrays
|
|
319
221
|
|
|
320
222
|
```typescript
|
|
321
|
-
|
|
322
|
-
|
|
223
|
+
// Spread over Object.assign
|
|
224
|
+
const updated = { ...original, name: "New Name" };
|
|
323
225
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
329
|
-
logEvent("ERROR_BOUNDARY", { error: error.message });
|
|
330
|
-
}
|
|
226
|
+
// Array methods over loops (unless breaking early)
|
|
227
|
+
const doubled = items.map((item) => item * 2);
|
|
228
|
+
const sorted = items.toSorted((a, b) => a - b); // Immutable
|
|
331
229
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
230
|
+
// For early exit
|
|
231
|
+
for (const item of items) {
|
|
232
|
+
if (condition) break;
|
|
335
233
|
}
|
|
336
234
|
```
|
|
337
235
|
|
|
338
|
-
##
|
|
339
|
-
|
|
340
|
-
- **Always handle errors** - Check `isError` state
|
|
341
|
-
- **User-friendly messages** - Don't show technical errors
|
|
342
|
-
- **Log for debugging** - Include context
|
|
343
|
-
- **Provide recovery actions** - Retry, dismiss, navigate
|
|
344
|
-
- **Different strategies** - Error boundary vs inline vs retry
|
|
236
|
+
## Async
|
|
345
237
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
238
|
+
```typescript
|
|
239
|
+
// async/await over .then()
|
|
240
|
+
async function fetchData(): Promise<Data> {
|
|
241
|
+
const response = await api.get("/data");
|
|
242
|
+
return response.data;
|
|
243
|
+
}
|
|
349
244
|
|
|
350
|
-
|
|
245
|
+
// Parallel
|
|
246
|
+
const results = await Promise.all(items.map(processItem));
|
|
351
247
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
│ └── useDeleteFeature.ts
|
|
358
|
-
├── components/ # Feature-specific components
|
|
359
|
-
│ ├── FeatureCard.tsx
|
|
360
|
-
│ ├── FeatureList.tsx
|
|
361
|
-
│ └── FeatureHeader.tsx
|
|
362
|
-
├── hooks/ # Feature-specific hooks (non-API)
|
|
363
|
-
│ ├── useFeatureLogic.ts
|
|
364
|
-
│ └── useFeatureState.ts
|
|
365
|
-
├── utils/ # Feature utilities
|
|
366
|
-
│ ├── formatFeature.ts
|
|
367
|
-
│ ├── formatFeature.test.ts
|
|
368
|
-
│ └── validateFeature.ts
|
|
369
|
-
├── __tests__/ # Integration tests (optional)
|
|
370
|
-
│ └── FeatureFlow.test.tsx
|
|
371
|
-
├── Page.tsx # Main page component
|
|
372
|
-
├── Router.tsx # Feature routes
|
|
373
|
-
├── paths.ts # Route paths
|
|
374
|
-
├── types.ts # Shared types
|
|
375
|
-
├── constants.ts # Constants
|
|
376
|
-
└── README.md # Feature documentation (optional)
|
|
248
|
+
// Sequential (when needed)
|
|
249
|
+
for (const item of items) {
|
|
250
|
+
// eslint-disable-next-line no-await-in-loop
|
|
251
|
+
await processItem(item);
|
|
252
|
+
}
|
|
377
253
|
```
|
|
378
254
|
|
|
379
|
-
##
|
|
380
|
-
|
|
381
|
-
- Components: `PascalCase.tsx` (`UserProfile.tsx`)
|
|
382
|
-
- Hooks: `camelCase.ts` (`useUserProfile.ts`)
|
|
383
|
-
- Utils: `camelCase.ts` (`formatDate.ts`)
|
|
384
|
-
- Types: `camelCase.types.ts` (`user.types.ts`)
|
|
385
|
-
- Tests: `ComponentName.test.tsx`
|
|
386
|
-
|
|
387
|
-
<!-- Source: .ruler/frontend/react-patterns.md -->
|
|
388
|
-
|
|
389
|
-
# React Component Patterns
|
|
390
|
-
|
|
391
|
-
## Component Structure
|
|
255
|
+
## Classes
|
|
392
256
|
|
|
393
257
|
```typescript
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
258
|
+
class UserService {
|
|
259
|
+
public async findById(request: FindByIdRequest): Promise<User> {}
|
|
260
|
+
private validateUser(user: User): boolean {}
|
|
397
261
|
}
|
|
398
262
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const [isEditing, setIsEditing] = useState(false);
|
|
403
|
-
|
|
404
|
-
// 2. Derived state (avoid useMemo for inexpensive operations)
|
|
405
|
-
const displayName = formatName(data);
|
|
406
|
-
|
|
407
|
-
// 3. Event handlers
|
|
408
|
-
const handleSave = useCallback(async () => {
|
|
409
|
-
await saveUser(data);
|
|
410
|
-
onUpdate?.(data);
|
|
411
|
-
}, [data, onUpdate]);
|
|
412
|
-
|
|
413
|
-
// 4. Early returns
|
|
414
|
-
if (isLoading) return <Loading />;
|
|
415
|
-
if (error) return <Error message={error.message} />;
|
|
416
|
-
if (!data) return <NotFound />;
|
|
417
|
-
|
|
418
|
-
// 5. Main render
|
|
419
|
-
return (
|
|
420
|
-
<Card>
|
|
421
|
-
<Typography>{displayName}</Typography>
|
|
422
|
-
<Button onClick={handleSave}>Save</Button>
|
|
423
|
-
</Card>
|
|
424
|
-
);
|
|
263
|
+
// Extract pure functions outside classes
|
|
264
|
+
function formatUserName(first: string, last: string): string {
|
|
265
|
+
return `${first} ${last}`;
|
|
425
266
|
}
|
|
426
267
|
```
|
|
427
268
|
|
|
428
|
-
|
|
269
|
+
<!-- Source: .ruler/frontend/customHooks.md -->
|
|
429
270
|
|
|
430
|
-
|
|
431
|
-
- Props interface: `Props` (co-located with component) or `ComponentNameProps` (exported/shared)
|
|
432
|
-
- Event handlers: `handle*` (`handleClick`, `handleSubmit`)
|
|
433
|
-
- Boolean props: `is*`, `has*`, `should*`
|
|
271
|
+
# Custom Hooks
|
|
434
272
|
|
|
435
|
-
##
|
|
273
|
+
## Naming
|
|
436
274
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
count: number;
|
|
442
|
-
onAction: () => void;
|
|
443
|
-
}
|
|
275
|
+
- Prefix with `use`
|
|
276
|
+
- Boolean: `useIs*`, `useHas*`, `useCan*`
|
|
277
|
+
- Data: `useGet*`, `use*Data`
|
|
278
|
+
- Actions: `useSubmit*`, `useCreate*`
|
|
444
279
|
|
|
445
|
-
|
|
446
|
-
type ButtonProps = { variant: "link"; href: string } | { variant: "button"; onClick: () => void };
|
|
280
|
+
## Structure
|
|
447
281
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
282
|
+
```typescript
|
|
283
|
+
export function useFeature(params: Params, options: Options = {}) {
|
|
284
|
+
const query = useQuery(...);
|
|
285
|
+
const computed = useMemo(() => transform(query.data), [query.data]);
|
|
286
|
+
const handleAction = useCallback(async () => { ... }, []);
|
|
287
|
+
|
|
288
|
+
return { data: computed, isLoading: query.isLoading, handleAction };
|
|
452
289
|
}
|
|
453
290
|
```
|
|
454
291
|
|
|
455
|
-
##
|
|
292
|
+
## Shared State with Constate
|
|
456
293
|
|
|
457
294
|
```typescript
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
return <UserList users={data} />;
|
|
295
|
+
import constate from "constate";
|
|
296
|
+
|
|
297
|
+
function useFilters() {
|
|
298
|
+
const [filters, setFilters] = useState<Filters>({});
|
|
299
|
+
return { filters, setFilters };
|
|
464
300
|
}
|
|
465
301
|
|
|
466
|
-
|
|
467
|
-
<Card>
|
|
468
|
-
<Card.Header title="User" />
|
|
469
|
-
<Card.Body>Content</Card.Body>
|
|
470
|
-
<Card.Actions>
|
|
471
|
-
<Button>Save</Button>
|
|
472
|
-
</Card.Actions>
|
|
473
|
-
</Card>
|
|
474
|
-
|
|
475
|
-
// Render props
|
|
476
|
-
<DataProvider>
|
|
477
|
-
{({ data, isLoading }) => (
|
|
478
|
-
isLoading ? <Loading /> : <Display data={data} />
|
|
479
|
-
)}
|
|
480
|
-
</DataProvider>
|
|
302
|
+
export const [FiltersProvider, useFiltersContext] = constate(useFilters);
|
|
481
303
|
```
|
|
482
304
|
|
|
483
|
-
|
|
305
|
+
Use constate for: sharing state between siblings, feature-level state.
|
|
306
|
+
Don't use for: server state (use React Query), simple parent-child (use props).
|
|
484
307
|
|
|
485
|
-
|
|
486
|
-
// Typed children
|
|
487
|
-
interface Props {
|
|
488
|
-
children: ReactNode;
|
|
489
|
-
}
|
|
308
|
+
<!-- Source: .ruler/frontend/dataFetching.md -->
|
|
490
309
|
|
|
491
|
-
|
|
492
|
-
interface Props {
|
|
493
|
-
children: (data: Data) => ReactNode;
|
|
494
|
-
}
|
|
310
|
+
# Data Fetching
|
|
495
311
|
|
|
496
|
-
|
|
497
|
-
interface Props {
|
|
498
|
-
children: ReactElement<ButtonProps>;
|
|
499
|
-
}
|
|
500
|
-
```
|
|
312
|
+
## Core Rules
|
|
501
313
|
|
|
502
|
-
|
|
314
|
+
1. Use React Query for all API calls
|
|
315
|
+
2. Define Zod schemas for all request/response types
|
|
316
|
+
3. Use `isLoading`, `isError`, `isSuccess`—don't create custom state
|
|
317
|
+
4. Use `enabled` option for conditional fetching
|
|
318
|
+
5. Use `invalidateQueries` (not `refetch`) for disabled queries
|
|
319
|
+
|
|
320
|
+
## Hook Pattern
|
|
503
321
|
|
|
504
322
|
```typescript
|
|
505
|
-
//
|
|
506
|
-
|
|
323
|
+
// Define in: FeatureName/api/useGetFeature.ts
|
|
324
|
+
const responseSchema = z.object({
|
|
325
|
+
id: z.string(),
|
|
326
|
+
name: z.string(),
|
|
327
|
+
});
|
|
507
328
|
|
|
508
|
-
|
|
509
|
-
{users.length === 0 ? <EmptyState /> : users.map(...)}
|
|
329
|
+
export type FeatureResponse = z.infer<typeof responseSchema>;
|
|
510
330
|
|
|
511
|
-
|
|
512
|
-
|
|
331
|
+
export function useGetFeature(id: string, options = {}) {
|
|
332
|
+
return useGetQuery({
|
|
333
|
+
url: `feature/${id}`,
|
|
334
|
+
responseSchema,
|
|
335
|
+
enabled: !!id,
|
|
336
|
+
meta: { logErrorMessage: APP_EVENTS.GET_FEATURE_FAILURE },
|
|
337
|
+
...options,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
513
340
|
```
|
|
514
341
|
|
|
515
|
-
##
|
|
342
|
+
## Query Keys
|
|
516
343
|
|
|
517
|
-
|
|
518
|
-
// Simple conditional
|
|
519
|
-
{isLoading && <Spinner />}
|
|
344
|
+
Include URL and params for predictable cache invalidation:
|
|
520
345
|
|
|
521
|
-
|
|
522
|
-
|
|
346
|
+
```typescript
|
|
347
|
+
queryKey: ["users", userId];
|
|
348
|
+
queryKey: ["users", userId, "posts"];
|
|
349
|
+
```
|
|
523
350
|
|
|
524
|
-
|
|
525
|
-
{isLoading ? <Loading /> : isError ? <Error /> : <Data />}
|
|
351
|
+
## Conditional Fetching
|
|
526
352
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
353
|
+
```typescript
|
|
354
|
+
const { data } = useGetFeature(
|
|
355
|
+
{ id: dependencyData?.id },
|
|
356
|
+
{ enabled: isDefined(dependencyData?.id) },
|
|
357
|
+
);
|
|
531
358
|
```
|
|
532
359
|
|
|
533
|
-
##
|
|
360
|
+
## Mutations
|
|
534
361
|
|
|
535
362
|
```typescript
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
// Only CountDisplay re-renders when count changes
|
|
545
|
-
return (
|
|
546
|
-
<>
|
|
547
|
-
<CountDisplay count={count} />
|
|
548
|
-
<TextInput value={text} onChange={setText} />
|
|
549
|
-
</>
|
|
550
|
-
);
|
|
363
|
+
export function useCreateItem() {
|
|
364
|
+
const queryClient = useQueryClient();
|
|
365
|
+
return useMutation({
|
|
366
|
+
mutationFn: (data: CreateItemRequest) => api.post("/items", data),
|
|
367
|
+
onSuccess: () => queryClient.invalidateQueries(["items"]),
|
|
368
|
+
onError: (error) => logError("CREATE_ITEM_FAILURE", error),
|
|
369
|
+
});
|
|
551
370
|
}
|
|
552
371
|
```
|
|
553
372
|
|
|
554
|
-
|
|
373
|
+
<!-- Source: .ruler/frontend/e2eTesting.md -->
|
|
555
374
|
|
|
556
|
-
|
|
557
|
-
- **Composition over Props** - Use children and compound components
|
|
558
|
-
- **Colocate State** - Keep state close to where it's used
|
|
559
|
-
- **Type Everything** - Full TypeScript coverage
|
|
560
|
-
- **Test Behavior** - Test user interactions, not implementation
|
|
375
|
+
# E2E Testing (Playwright)
|
|
561
376
|
|
|
562
|
-
|
|
377
|
+
## Core Rules
|
|
563
378
|
|
|
564
|
-
|
|
379
|
+
- Test critical user flows only—not exhaustive scenarios
|
|
380
|
+
- Each test sets up its own data (no shared state between tests)
|
|
381
|
+
- Mock feature flags and third-party services
|
|
382
|
+
- Use user-centric locators (role, label, text)—avoid CSS selectors
|
|
565
383
|
|
|
566
|
-
|
|
567
|
-
- Prefer inline JSX over extracted variables
|
|
568
|
-
- Use custom hooks to encapsulate and reuse stateful logic
|
|
569
|
-
- Use Zod for request/response schemas in data-fetching hooks
|
|
570
|
-
- Use react-hook-form with Zod resolver for forms
|
|
571
|
-
- Use date-fns for date operations
|
|
384
|
+
## Locator Priority
|
|
572
385
|
|
|
573
|
-
|
|
386
|
+
1. `page.getByRole()`
|
|
387
|
+
2. `page.getByLabel()`
|
|
388
|
+
3. `page.getByPlaceholder()`
|
|
389
|
+
4. `page.getByText()`
|
|
390
|
+
5. `page.getByTestId()` (last resort)
|
|
574
391
|
|
|
575
|
-
|
|
392
|
+
## Assertions
|
|
576
393
|
|
|
577
|
-
|
|
394
|
+
```typescript
|
|
395
|
+
// ✅ Assert visibility
|
|
396
|
+
await expect(page.getByText("Submit")).toBeVisible();
|
|
578
397
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
4. **Use semantic names** - `theme.palette.text.primary`, not `common.white`
|
|
583
|
-
5. **Check Storybook first** - It's the single source of truth
|
|
398
|
+
// ❌ Don't assert DOM attachment
|
|
399
|
+
await expect(page.getByText("Submit")).toBeAttached();
|
|
400
|
+
```
|
|
584
401
|
|
|
585
|
-
##
|
|
402
|
+
## Avoid
|
|
586
403
|
|
|
587
|
-
|
|
404
|
+
- Hard-coded timeouts (`page.waitForTimeout`)
|
|
405
|
+
- Testing loading states (non-deterministic)
|
|
406
|
+
- Shared data between tests
|
|
407
|
+
- CSS/XPath selectors
|
|
588
408
|
|
|
589
|
-
|
|
590
|
-
- CSS/SCSS/SASS files
|
|
591
|
-
- Inline `style` prop (use `sx` instead)
|
|
592
|
-
- String paths for theme (`"text.secondary"` - not type-safe)
|
|
409
|
+
<!-- Source: .ruler/frontend/errorHandling.md -->
|
|
593
410
|
|
|
594
|
-
|
|
411
|
+
# Error Handling
|
|
595
412
|
|
|
596
|
-
|
|
597
|
-
// ✅ Always use function form for type safety
|
|
598
|
-
<Box sx={(theme) => ({
|
|
599
|
-
backgroundColor: theme.palette.background.primary,
|
|
600
|
-
color: theme.palette.text.secondary,
|
|
601
|
-
padding: theme.spacing(4), // or just: 4
|
|
602
|
-
})} />
|
|
603
|
-
|
|
604
|
-
// ❌ Never hardcode
|
|
605
|
-
<Box sx={{
|
|
606
|
-
backgroundColor: "red", // ❌ Raw color
|
|
607
|
-
padding: "16px", // ❌ Raw size
|
|
608
|
-
color: "text.secondary", // ❌ String path
|
|
609
|
-
}} />
|
|
610
|
-
```
|
|
611
|
-
|
|
612
|
-
## Spacing System
|
|
613
|
-
|
|
614
|
-
Use indices 1-12 (4px-64px):
|
|
413
|
+
## Component Level
|
|
615
414
|
|
|
616
415
|
```typescript
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
<Box sx={{ gap: 3 }} /> // → 8px
|
|
620
|
-
```
|
|
416
|
+
export function DataComponent() {
|
|
417
|
+
const { data, isLoading, isError, refetch } = useGetData();
|
|
621
418
|
|
|
622
|
-
|
|
419
|
+
if (isLoading) return <LoadingState />;
|
|
420
|
+
if (isError) return <ErrorState onRetry={refetch} />;
|
|
623
421
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
height: "3rem", // ✅ Scales
|
|
627
|
-
fontSize: theme.typography.body1.fontSize,
|
|
628
|
-
padding: 5, // ✅ Prevents overflow
|
|
629
|
-
})} />
|
|
422
|
+
return <DataDisplay data={data} />;
|
|
423
|
+
}
|
|
630
424
|
```
|
|
631
425
|
|
|
632
|
-
##
|
|
426
|
+
## Mutation Level
|
|
633
427
|
|
|
634
428
|
```typescript
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
429
|
+
useMutation({
|
|
430
|
+
mutationFn: createItem,
|
|
431
|
+
onSuccess: () => showSuccessToast("Created"),
|
|
432
|
+
onError: (error) => {
|
|
433
|
+
logError("CREATE_FAILURE", error);
|
|
434
|
+
showErrorToast("Failed to create");
|
|
435
|
+
},
|
|
436
|
+
});
|
|
639
437
|
```
|
|
640
438
|
|
|
641
|
-
##
|
|
439
|
+
## Validation
|
|
642
440
|
|
|
643
441
|
```typescript
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
442
|
+
try {
|
|
443
|
+
const validated = formSchema.parse(formData);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
if (error instanceof z.ZodError) {
|
|
446
|
+
error.errors.forEach((err) => showFieldError(err.path.join("."), err.message));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
649
449
|
```
|
|
650
450
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
✅ Use full names: `padding`, `paddingX`, `marginY`
|
|
654
|
-
❌ Avoid abbreviations: `p`, `px`, `my`
|
|
655
|
-
|
|
656
|
-
## Layout Components
|
|
451
|
+
<!-- Source: .ruler/frontend/fileOrganization.md -->
|
|
657
452
|
|
|
658
|
-
|
|
453
|
+
# File Organization
|
|
659
454
|
|
|
660
|
-
```
|
|
661
|
-
|
|
455
|
+
```text
|
|
456
|
+
FeatureName/
|
|
457
|
+
├── api/ # Data fetching hooks
|
|
458
|
+
│ └── useGetFeature.ts
|
|
459
|
+
├── components/ # Feature-specific components
|
|
460
|
+
│ └── FeatureCard.tsx
|
|
461
|
+
├── hooks/ # Non-API hooks
|
|
462
|
+
│ └── useFeatureLogic.ts
|
|
463
|
+
├── utils/ # Utilities + tests
|
|
464
|
+
│ ├── formatFeature.ts
|
|
465
|
+
│ └── formatFeature.test.ts
|
|
466
|
+
├── Page.tsx # Main page
|
|
467
|
+
├── Router.tsx # Routes
|
|
468
|
+
├── paths.ts # Route constants
|
|
469
|
+
└── types.ts # Shared types
|
|
662
470
|
```
|
|
663
471
|
|
|
664
|
-
##
|
|
472
|
+
## Naming
|
|
665
473
|
|
|
666
|
-
-
|
|
667
|
-
-
|
|
668
|
-
-
|
|
474
|
+
- Components: `PascalCase.tsx`
|
|
475
|
+
- Hooks: `camelCase.ts` (prefixed with `use`)
|
|
476
|
+
- Utils: `camelCase.ts`
|
|
477
|
+
- Tests: `*.test.tsx`
|
|
669
478
|
|
|
670
|
-
<!-- Source: .ruler/frontend/
|
|
479
|
+
<!-- Source: .ruler/frontend/frontendTechnologyStack.md -->
|
|
671
480
|
|
|
672
|
-
#
|
|
481
|
+
# Frontend Technology Stack
|
|
673
482
|
|
|
674
|
-
|
|
483
|
+
- **React** with TypeScript (strict mode)
|
|
484
|
+
- **MUI** for UI components
|
|
485
|
+
- **React Query** (@tanstack/react-query) for data fetching
|
|
486
|
+
- **Zod** for runtime validation
|
|
487
|
+
- **Vitest** + **@testing-library/react** for testing
|
|
488
|
+
- **MSW** for API mocking
|
|
489
|
+
- **Playwright** for E2E tests
|
|
490
|
+
- **constate** for shared state
|
|
675
491
|
|
|
676
|
-
|
|
492
|
+
<!-- Source: .ruler/frontend/interactiveElements.md -->
|
|
677
493
|
|
|
678
|
-
|
|
494
|
+
# Interactive Elements
|
|
679
495
|
|
|
680
|
-
|
|
681
|
-
2. **Integration** - Most valuable, test features not isolated components
|
|
682
|
-
3. **Unit** - For pure helpers/utilities only
|
|
683
|
-
4. **E2E** - Critical flows only
|
|
496
|
+
Never add `onClick` to `div` or `span`. Use:
|
|
684
497
|
|
|
685
|
-
|
|
498
|
+
- `<button>` for actions
|
|
499
|
+
- `<a>` (Link) for navigation
|
|
500
|
+
- MUI interactive components (`Button`, `IconButton`, `ListItemButton`)
|
|
686
501
|
|
|
687
|
-
|
|
502
|
+
This ensures proper accessibility: focus states, keyboard navigation, ARIA roles.
|
|
688
503
|
|
|
689
|
-
|
|
690
|
-
- **@testing-library/react** for component testing
|
|
691
|
-
- **@testing-library/user-event** for user interactions
|
|
692
|
-
- **Mock Service Worker (MSW)** for API mocking
|
|
504
|
+
<!-- Source: .ruler/frontend/modalRoutes.md -->
|
|
693
505
|
|
|
694
|
-
|
|
506
|
+
# Modal Routes
|
|
695
507
|
|
|
696
|
-
|
|
697
|
-
describe("ComponentName", () => {
|
|
698
|
-
it("should render correctly", () => {
|
|
699
|
-
render(<Component />);
|
|
700
|
-
expect(screen.getByText("Expected")).toBeInTheDocument();
|
|
701
|
-
});
|
|
508
|
+
Modal visibility is driven by URL, not local state:
|
|
702
509
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
510
|
+
```typescript
|
|
511
|
+
<ModalRoute
|
|
512
|
+
path={`${basePath}/confirm`}
|
|
513
|
+
closeModalPath={basePath}
|
|
514
|
+
render={({ modalState }) => (
|
|
515
|
+
<ConfirmDialog modalState={modalState} />
|
|
516
|
+
)}
|
|
517
|
+
/>
|
|
710
518
|
```
|
|
711
519
|
|
|
712
|
-
|
|
520
|
+
Use `history.replace` (not `push`) when navigating between modals to avoid awkward back-button behavior.
|
|
713
521
|
|
|
714
|
-
|
|
522
|
+
<!-- Source: .ruler/frontend/reactComponents.md -->
|
|
715
523
|
|
|
716
|
-
|
|
524
|
+
# React Components
|
|
717
525
|
|
|
718
|
-
|
|
719
|
-
2. `getByLabelText` - For form fields
|
|
720
|
-
3. `getByText` - For non-interactive content
|
|
721
|
-
4. `getByTestId` - ⚠️ **LAST RESORT**
|
|
526
|
+
## Type vs Interface
|
|
722
527
|
|
|
723
528
|
```typescript
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
screen.getByClassName("user-card");
|
|
730
|
-
screen.getByTestId("element");
|
|
529
|
+
// Use interface for: props, object shapes, extensible types
|
|
530
|
+
interface UserCardProps {
|
|
531
|
+
user: User;
|
|
532
|
+
onSelect: (id: string) => void;
|
|
533
|
+
}
|
|
731
534
|
```
|
|
732
535
|
|
|
733
|
-
##
|
|
536
|
+
## Structure Order
|
|
734
537
|
|
|
735
538
|
```typescript
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
const
|
|
739
|
-
|
|
539
|
+
export function Component({ userId, onUpdate }: Props) {
|
|
540
|
+
// 1. Hooks
|
|
541
|
+
const { data, isLoading } = useGetUser(userId);
|
|
542
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
740
543
|
|
|
741
|
-
|
|
742
|
-
|
|
544
|
+
// 2. Derived state (no useMemo for cheap operations)
|
|
545
|
+
const displayName = formatName(data);
|
|
743
546
|
|
|
744
|
-
|
|
745
|
-
});
|
|
746
|
-
```
|
|
547
|
+
// 3. Event handlers
|
|
548
|
+
const handleSave = useCallback(async () => { ... }, [deps]);
|
|
747
549
|
|
|
748
|
-
|
|
550
|
+
// 4. Early returns for loading/error/empty
|
|
551
|
+
if (isLoading) return <Loading />;
|
|
552
|
+
if (!data) return <NotFound />;
|
|
749
553
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const { result } = renderHook(() => useCustomHook());
|
|
754
|
-
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
755
|
-
expect(result.current.data).toEqual(expectedData);
|
|
756
|
-
});
|
|
757
|
-
});
|
|
554
|
+
// 5. Main render
|
|
555
|
+
return <Card>...</Card>;
|
|
556
|
+
}
|
|
758
557
|
```
|
|
759
558
|
|
|
760
|
-
##
|
|
559
|
+
## Naming Conventions
|
|
761
560
|
|
|
762
|
-
|
|
561
|
+
- Components: `PascalCase`
|
|
562
|
+
- Event handlers: `handle*` (e.g., `handleClick`)
|
|
563
|
+
- Props interface: `Props` (co-located) or `ComponentNameProps` (exported)
|
|
763
564
|
|
|
764
|
-
|
|
765
|
-
// ✅ Good - flexible per test
|
|
766
|
-
export const createTestHandler = (data: Data[]) =>
|
|
767
|
-
rest.get(`/api/resource`, (req, res, ctx) => res(ctx.json(data)));
|
|
565
|
+
## Component Guidelines
|
|
768
566
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
567
|
+
- **One file per component**—never extract JSX into local variables
|
|
568
|
+
- **Composition over configuration**—prefer `children` over many props
|
|
569
|
+
- **Presentational components**: stateless, UI-focused, no API calls
|
|
570
|
+
- **Container components**: feature logic, data fetching, state management
|
|
571
|
+
- Pass primitives as props, not entire API response objects
|
|
774
572
|
|
|
775
573
|
```typescript
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
## What to Test
|
|
782
|
-
|
|
783
|
-
**✅ Do Test:**
|
|
784
|
-
|
|
785
|
-
- Integration tests for features
|
|
786
|
-
- Unit tests for utilities
|
|
787
|
-
- All states (loading, success, error)
|
|
788
|
-
- User interactions
|
|
789
|
-
|
|
790
|
-
**❌ Don't Test:**
|
|
791
|
-
|
|
792
|
-
- Implementation details
|
|
793
|
-
- Third-party libraries
|
|
794
|
-
- Styles/CSS
|
|
795
|
-
|
|
796
|
-
<!-- Source: .ruler/frontend/typeScript.md -->
|
|
797
|
-
|
|
798
|
-
# TypeScript Standards
|
|
574
|
+
// ❌ Bad—coupled to API shape
|
|
575
|
+
interface Props {
|
|
576
|
+
shift: ShiftApiResponse;
|
|
577
|
+
}
|
|
799
578
|
|
|
800
|
-
|
|
579
|
+
// ✅ Good—only required fields
|
|
580
|
+
interface Props {
|
|
581
|
+
shiftId: string;
|
|
582
|
+
shiftPay: number;
|
|
583
|
+
workplaceId: string;
|
|
584
|
+
}
|
|
585
|
+
```
|
|
801
586
|
|
|
802
|
-
|
|
803
|
-
- **Prefer type inference** - Let TypeScript infer when possible
|
|
804
|
-
- **Explicit return types** for exported functions
|
|
805
|
-
- **Zod for runtime validation** - Single source of truth
|
|
806
|
-
- **No type assertions** unless unavoidable
|
|
807
|
-
|
|
808
|
-
## Type vs Interface
|
|
587
|
+
## Inline JSX and Handlers
|
|
809
588
|
|
|
810
589
|
```typescript
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
onSelect: (id: string) => void;
|
|
815
|
-
}
|
|
590
|
+
// ❌ Don't extract JSX to variables
|
|
591
|
+
const content = <p>Content</p>;
|
|
592
|
+
return <>{content}</>;
|
|
816
593
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
createdAt: string;
|
|
820
|
-
}
|
|
594
|
+
// ✅ Keep inline or extract to new component file
|
|
595
|
+
return <p>Content</p>;
|
|
821
596
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
}
|
|
597
|
+
// ❌ Don't extract handlers unnecessarily
|
|
598
|
+
const handleChange = (e) => { ... };
|
|
599
|
+
return <Input onChange={handleChange} />;
|
|
825
600
|
|
|
826
|
-
// ✅
|
|
827
|
-
|
|
828
|
-
type UserWithRoles = User & { roles: string[] };
|
|
829
|
-
type Coordinate = [number, number];
|
|
830
|
-
type UserKeys = keyof User;
|
|
601
|
+
// ✅ Keep inline
|
|
602
|
+
return <Input onChange={(e) => { ... }} />;
|
|
831
603
|
```
|
|
832
604
|
|
|
833
|
-
##
|
|
605
|
+
## List Rendering
|
|
834
606
|
|
|
835
607
|
```typescript
|
|
836
|
-
// ✅
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
// ✅ Boolean properties
|
|
842
|
-
interface User {
|
|
843
|
-
isActive: boolean;
|
|
844
|
-
hasPermission: boolean;
|
|
845
|
-
shouldNotify: boolean;
|
|
846
|
-
}
|
|
608
|
+
// ✅ Always use stable keys
|
|
609
|
+
{users.map((user) => <UserCard key={user.id} user={user} />)}
|
|
610
|
+
|
|
611
|
+
// ❌ Never use index as key for dynamic lists
|
|
612
|
+
{items.map((item, i) => <Item key={i} />)}
|
|
847
613
|
```
|
|
848
614
|
|
|
849
|
-
|
|
615
|
+
<!-- Source: .ruler/frontend/styling.md -->
|
|
850
616
|
|
|
851
|
-
|
|
852
|
-
// Define schema, infer type
|
|
853
|
-
const userSchema = z.object({
|
|
854
|
-
id: z.string(),
|
|
855
|
-
name: z.string(),
|
|
856
|
-
});
|
|
617
|
+
# Styling
|
|
857
618
|
|
|
858
|
-
|
|
619
|
+
## Core Rules
|
|
859
620
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
});
|
|
865
|
-
```
|
|
621
|
+
1. **Always use `sx` prop**—never CSS/SCSS/SASS/styled()/makeStyles()
|
|
622
|
+
2. **Use theme tokens**—never use raw string values for colors/spacing
|
|
623
|
+
3. **Type-safe theme access**—use `sx={(theme) => ({...})}`
|
|
624
|
+
4. **Use semantic token names**—`theme.palette.text.primary`, not `common.white`
|
|
866
625
|
|
|
867
|
-
##
|
|
626
|
+
## Patterns
|
|
868
627
|
|
|
869
628
|
```typescript
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
629
|
+
// ✅ Correct
|
|
630
|
+
<Box sx={(theme) => ({
|
|
631
|
+
backgroundColor: theme.palette.background.primary,
|
|
632
|
+
color: theme.palette.text.secondary,
|
|
633
|
+
padding: theme.spacing(4), // or just: padding: 4
|
|
634
|
+
})} />
|
|
873
635
|
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
636
|
+
// ❌ Wrong
|
|
637
|
+
<Box sx={{
|
|
638
|
+
backgroundColor: "red", // raw color
|
|
639
|
+
padding: "16px", // raw size
|
|
640
|
+
color: "text.secondary", // string path (not type-safe)
|
|
641
|
+
}} />
|
|
878
642
|
```
|
|
879
643
|
|
|
880
|
-
##
|
|
881
|
-
|
|
882
|
-
```typescript
|
|
883
|
-
// Use const assertions
|
|
884
|
-
export const STATUSES = {
|
|
885
|
-
DRAFT: "draft",
|
|
886
|
-
PUBLISHED: "published",
|
|
887
|
-
} as const;
|
|
888
|
-
|
|
889
|
-
export type Status = (typeof STATUSES)[keyof typeof STATUSES];
|
|
890
|
-
```
|
|
644
|
+
## Spacing
|
|
891
645
|
|
|
892
|
-
|
|
646
|
+
Use theme spacing indices 1-12:
|
|
893
647
|
|
|
894
648
|
```typescript
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
type RequiredUser = Required<User>;
|
|
898
|
-
type ReadonlyUser = Readonly<User>;
|
|
899
|
-
type UserIdOnly = Pick<User, "id" | "name">;
|
|
900
|
-
type UserWithoutPassword = Omit<User, "password">;
|
|
901
|
-
type UserMap = Record<string, User>;
|
|
902
|
-
type DefinedString = NonNullable<string | null>;
|
|
903
|
-
|
|
904
|
-
// Function utilities
|
|
905
|
-
type GetUserResult = ReturnType<typeof getUser>;
|
|
906
|
-
type UpdateUserParams = Parameters<typeof updateUser>;
|
|
649
|
+
<Box sx={{ padding: 5 }} />
|
|
650
|
+
<Box sx={{ marginX: 4 }} />
|
|
907
651
|
```
|
|
908
652
|
|
|
909
|
-
|
|
653
|
+
Use `rem` for fonts/heights (scales with user zoom), spacing indices for padding/margin.
|
|
910
654
|
|
|
911
|
-
|
|
912
|
-
// ❌ Bad - Loses type safety
|
|
913
|
-
function process(data: any) { ... }
|
|
655
|
+
## Property Names
|
|
914
656
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
if (typeof data === "object" && data !== null) {
|
|
918
|
-
// Type guard
|
|
919
|
-
}
|
|
920
|
-
}
|
|
657
|
+
- ✅ Use full names: `padding`, `paddingX`, `marginY`
|
|
658
|
+
- ❌ Avoid abbreviations: `p`, `px`, `my`
|
|
921
659
|
|
|
922
|
-
|
|
923
|
-
function process<T extends { value: string }>(data: T) {
|
|
924
|
-
return data.value;
|
|
925
|
-
}
|
|
660
|
+
## Pseudo-classes
|
|
926
661
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
}
|
|
662
|
+
```typescript
|
|
663
|
+
<Box sx={(theme) => ({
|
|
664
|
+
"&:hover": { backgroundColor: theme.palette.primary.dark },
|
|
665
|
+
"&:disabled": { opacity: 0.5 },
|
|
666
|
+
"& .MuiTypography-root": { color: theme.palette.text.secondary },
|
|
667
|
+
})} />
|
|
933
668
|
```
|
|
934
669
|
|
|
935
|
-
|
|
670
|
+
<!-- Source: .ruler/frontend/testing.md -->
|
|
936
671
|
|
|
937
|
-
|
|
938
|
-
// Generic function
|
|
939
|
-
function getFirst<T>(array: T[]): T | undefined {
|
|
940
|
-
return array[0];
|
|
941
|
-
}
|
|
672
|
+
# Testing
|
|
942
673
|
|
|
943
|
-
|
|
944
|
-
interface ListProps<T> {
|
|
945
|
-
items: T[];
|
|
946
|
-
renderItem: (item: T) => ReactNode;
|
|
947
|
-
}
|
|
674
|
+
## Philosophy
|
|
948
675
|
|
|
949
|
-
|
|
950
|
-
return <>{items.map((item) => renderItem(item))}</>;
|
|
951
|
-
}
|
|
676
|
+
Focus on integration tests—test how components work together as users experience them.
|
|
952
677
|
|
|
953
|
-
|
|
954
|
-
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
|
|
955
|
-
return items.find((item) => item.id === id);
|
|
956
|
-
}
|
|
957
|
-
```
|
|
678
|
+
**Priority:** Static (TS/ESLint) → Integration → Unit (pure utilities only) → E2E (critical flows only)
|
|
958
679
|
|
|
959
|
-
##
|
|
680
|
+
## Test Structure
|
|
960
681
|
|
|
961
682
|
```typescript
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
| { status: "success"; data: T }
|
|
967
|
-
| { status: "error"; error: Error };
|
|
968
|
-
|
|
969
|
-
// Automatic type narrowing
|
|
970
|
-
if (state.status === "success") {
|
|
971
|
-
console.log(state.data); // TypeScript knows data exists
|
|
972
|
-
}
|
|
683
|
+
describe("ComponentName", () => {
|
|
684
|
+
it("should [behavior] when [condition]", async () => {
|
|
685
|
+
const user = userEvent.setup();
|
|
686
|
+
render(<Component />);
|
|
973
687
|
|
|
974
|
-
|
|
975
|
-
type Action = { type: "SET_USER"; payload: User } | { type: "CLEAR_USER" };
|
|
688
|
+
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
976
689
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
return { ...state, user: null };
|
|
983
|
-
}
|
|
984
|
-
}
|
|
690
|
+
await waitFor(() => {
|
|
691
|
+
expect(screen.getByText("Success")).toBeInTheDocument();
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
});
|
|
985
695
|
```
|
|
986
696
|
|
|
987
|
-
##
|
|
697
|
+
## Query Priority
|
|
988
698
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
return value.toUpperCase();
|
|
994
|
-
}
|
|
995
|
-
return value.toFixed(2);
|
|
996
|
-
}
|
|
699
|
+
1. `getByRole` (best for accessibility)
|
|
700
|
+
2. `getByLabelText` (form fields)
|
|
701
|
+
3. `getByText` (non-interactive content)
|
|
702
|
+
4. `getByTestId` (last resort only)
|
|
997
703
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
} else {
|
|
1003
|
-
animal.fly();
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
704
|
+
```typescript
|
|
705
|
+
// ✅ Prefer
|
|
706
|
+
screen.getByRole("button", { name: /submit/i });
|
|
707
|
+
screen.getByLabelText("Email");
|
|
1006
708
|
|
|
1007
|
-
//
|
|
1008
|
-
|
|
1009
|
-
if (error instanceof Error) {
|
|
1010
|
-
console.log(error.message);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
709
|
+
// ❌ Avoid
|
|
710
|
+
screen.getByTestId("submit-button");
|
|
1013
711
|
```
|
|
1014
712
|
|
|
1015
|
-
##
|
|
1016
|
-
|
|
1017
|
-
```typescript
|
|
1018
|
-
// Optional chaining
|
|
1019
|
-
const userName = user?.profile?.name;
|
|
713
|
+
## MSW Handlers
|
|
1020
714
|
|
|
1021
|
-
|
|
1022
|
-
const displayName = userName ?? "Anonymous";
|
|
715
|
+
Export factory functions, not static handlers:
|
|
1023
716
|
|
|
1024
|
-
|
|
1025
|
-
|
|
717
|
+
```typescript
|
|
718
|
+
// ✅ Good—flexible per test
|
|
719
|
+
export const createUserHandler = (userData: User) =>
|
|
720
|
+
rest.get("/api/user", (req, res, ctx) => res(ctx.json(userData)));
|
|
1026
721
|
|
|
1027
|
-
//
|
|
1028
|
-
|
|
1029
|
-
type EventHandler = `on${Capitalize<string>}`;
|
|
722
|
+
// Usage
|
|
723
|
+
mockServer.use(createUserHandler(customData));
|
|
1030
724
|
```
|
|
1031
725
|
|
|
1032
|
-
##
|
|
726
|
+
## What to Test
|
|
1033
727
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
- **Zod** for runtime validation
|
|
1037
|
-
- **Type guards** over type assertions
|
|
1038
|
-
- **Const assertions** for readonly values
|
|
1039
|
-
- **Utility types** to transform existing types
|
|
728
|
+
✅ Test: user interactions, all states (loading/success/error), integration between components
|
|
729
|
+
❌ Don't test: implementation details, third-party libraries, styles
|