Skip to content

Commit 3220aa3

Browse files
authored
Refactored strikes component (#305)
* added global error handling for queries as well * cerated new model types for strikes * added a key factory for strikes * added a mappers for domain types * added a doc that explains the structure
1 parent 968b398 commit 3220aa3

File tree

14 files changed

+372
-122
lines changed

14 files changed

+372
-122
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
"cSpell.words": [
44
"Docxtemplater",
55
"Gotenberg"
6-
]
6+
],
77
}

client/README.md

Lines changed: 175 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ Frontend application for HackYourFuture's trainee management system
77
![Vite](https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white)
88
![MUI](https://img.shields.io/badge/MUI-%23007FFF.svg?style=for-the-badge&logo=mui&logoColor=white)
99

10-
1110
## Technology Stack
11+
1212
- **React** - Modern React with hooks and concurrent features
1313
- **TypeScript**
1414
- **Vite** - For development server
@@ -26,16 +26,18 @@ Before you begin, ensure you have the following installed:
2626
## Setup
2727

2828
1. **Install dependencies**:
29+
2930
```bash
3031
npm run setup
3132
```
3233

3334
2. **Set up environment variables**:
3435
Create a `.env` file in the client directory:
36+
3537
```bash
3638
# Backend API URL (optional - defaults to http://localhost:7777)
3739
VITE_BACKEND_PROXY_TARGET=http://localhost:7777
38-
40+
3941
# Google OAuth Client ID (required for authentication)
4042
VITE_GOOGLE_CLIENT_ID=your_google_client_id_here
4143
```
@@ -62,20 +64,23 @@ The build files will be generated in the `dist/` directory.
6264

6365
### Environment Variables
6466

65-
| Variable | Description | Default | Required |
66-
|----------|-------------|---------|----------|
67-
| `VITE_BACKEND_PROXY_TARGET` | Backend API URL | `http://localhost:7777` | No |
68-
| `VITE_GOOGLE_CLIENT_ID` | Google OAuth Client ID | - | Yes |
67+
| Variable | Description | Default | Required |
68+
| --------------------------- | ---------------------- | ----------------------- | -------- |
69+
| `VITE_BACKEND_PROXY_TARGET` | Backend API URL | `http://localhost:7777` | No |
70+
| `VITE_GOOGLE_CLIENT_ID` | Google OAuth Client ID | - | Yes |
6971

7072
### API Proxy Configuration
73+
7174
You need to set up `VITE_BACKEND_PROXY_TARGET` variable to point to the correct backend URL. If you use the default http://localhost:7777, you need to run the local server first. Read more about local backend development in the server's [README.md](../server/)
7275
The development server automatically proxies API requests:
76+
7377
- `/api/*` → Backend server
7478
- `/api-docs/*` → Backend API documentation
7579

7680
This eliminates CORS issues during development.
7781

7882
## 🗂️ Client Structure
83+
7984
- `src/`: Contains all React components and application logic.
8085
- `assets/`: Contains all the assets and images used.
8186
- `components/`: Reusable UI components.
@@ -98,7 +103,171 @@ Make sure you have `VITE_GOOGLE_CLIENT_ID` set up correctly. Check out the serve
98103
## API Integration
99104

100105
The client communicates with the backend API through:
106+
101107
- **Axios** for HTTP requests
102108
- **React Query** for caching and state management
103109
- **Automatic retry** for failed requests
104110
- **Optimistic updates** for better UX
111+
112+
---
113+
114+
## Using TanStack: Example with Strikes Feature (wip)
115+
116+
This project uses React Query's `useQuery` and `useMutation` hooks for data fetching and updates. Here’s how to organize your files and use these hooks, using the `strikes` feature as an example:
117+
118+
### File Structure for API and Data Layers
119+
120+
```
121+
strikes/
122+
├── api/
123+
│ ├── api.ts # API calls (fetch, add, edit, delete)
124+
│ ├── mapper.ts # Maps API types to domain models
125+
│ ├── types.ts # API request/response types
126+
├── data/
127+
│ ├── keys.ts # Query key factory for React Query
128+
│ ├── mutations.ts # React Query mutation hooks
129+
│ ├── strike-queries.ts # React Query query hooks
130+
```
131+
132+
- `api.ts`: clean calls using axios
133+
- `mapper.ts`: Sometimes we get from the backend more information than we are using in our UI, or the information is arranged differently. Because of this, it's good to separate the business logic that transforms API responses into the shape your UI needs.
134+
This file contains functions that map API types (often matching backend structure) to domain models (used in your frontend), ensuring consistency and making it easier to adapt if the backend changes or if you want to refactor your UI.
135+
For example, you might convert snake_case fields to camelCase, filter out unused properties, or maybe flatten nested data/
136+
- `types.ts`: specifies the request and response type. This way it's very clear to see what data is sent to the backend and what we expect to get back.
137+
138+
-`keys.ts`: In this file we define the key factory for the queries used in the feature. Query keys are unique identifiers for each query in React Query. They help React Query know which data to cache, refetch, or update.
139+
A key factory is a function or object that generates consistent, structured keys for your queries. This makes it easy to manage cache and invalidation, especially as your feature grows.
140+
141+
- `mutations.ts`: This file contains React Query mutation hooks for creating, updating, or deleting data. Mutations trigger changes on the server and, on success, typically invalidate relevant queries to keep the UI in sync.
142+
- `queries.ts`: This file contains React Query query hooks for fetching data from the server. Queries use structured keys to manage caching, loading states, and automatic refetching, making data fetching reliable and efficient.
143+
144+
### Example: Fetching Strikes with useQuery
145+
146+
`data/strike-queries.ts`:
147+
148+
```typescript
149+
import { useQuery } from '@tanstack/react-query';
150+
import { getStrikes } from '../api/api';
151+
import { strikeKeys } from './keys';
152+
153+
export const useGetStrikes = (traineeId: string) =>
154+
useQuery({
155+
queryKey: strikeKeys.list(traineeId),
156+
queryFn: () => getStrikes(traineeId),
157+
});
158+
```
159+
160+
`data/keys.ts`:
161+
162+
```typescript
163+
export const strikeKeys = {
164+
all: ['strikes'],
165+
list: (traineeId: string) => [...strikeKeys.all, 'list', traineeId],
166+
};
167+
```
168+
169+
💡 **Note:** This function creates two query keys for us.
170+
171+
1. all: the key looks like this: ['strikes']
172+
2. list: the key looks like this: ['strikes', 'list', `traineeId`].
173+
And when invalidating the cache, if you use `queryKey: strikeKeys.list(traineeId)`, it invalidates the cache for this specific traineeId. But if you call `queryKey: strikeKeys.all()`, it will invalidate all the cache queries that start with 'strikes'. Which is pretty cool :)
174+
175+
`api/api.ts`:
176+
177+
```typescript
178+
import axios from 'axios';
179+
import { StrikeResponse } from './types';
180+
import { mapStrikeToDomain } from './mapper';
181+
182+
export const getStrikes = async (traineeId: string) => {
183+
const { data } = await axios.get<StrikeResponse[]>(`/api/trainees/${traineeId}/strikes`);
184+
return data.map((strike) => mapStrikeToDomain(strike));
185+
};
186+
```
187+
188+
As you can see, the reporter details we get from the backend are nested.
189+
190+
```typescript
191+
export interface StrikeResponse {
192+
id: string;
193+
comments: string;
194+
date: string; // ISO string from backend
195+
reason: StrikeReason;
196+
reporter: ReporterDTO;
197+
}
198+
199+
interface ReporterDTO {
200+
id: string;
201+
name: string;
202+
imageUrl?: string;
203+
}
204+
```
205+
206+
And the strikes model that is used in the frontend is flattend. We are also ignoring the reported id because it is not used in the Strikes component.
207+
208+
```typescript
209+
// models/strike.ts
210+
export interface Strike {
211+
id: string;
212+
comments: string;
213+
date: Date;
214+
reason: StrikeReason;
215+
reporterName?: string;
216+
reporterImageUrl?: string;
217+
}
218+
```
219+
220+
### Using the Hook in a Component
221+
222+
```typescript
223+
const { data: strikes, isPending } = useGetStrikes(traineeId);
224+
```
225+
226+
### Example: Mutating Strikes Data
227+
228+
To add, edit, or delete a strike, use a mutation hook from `data/mutations.ts`:
229+
230+
```typescript
231+
export const useAddStrike = (traineeId: string) => {
232+
const queryClient = useQueryClient();
233+
234+
return useMutation({
235+
mutationFn: (strike: Strike) => {
236+
return addStrike(traineeId, strike);
237+
},
238+
onSuccess: async () => await invalidateStrikesQuery(queryClient, traineeId),
239+
});
240+
};
241+
```
242+
243+
It’s important to invalidate the relevant query after a mutation completes. While the mutation is still pending, React Query will keep the loading state active until the operation finishes and the cache is refreshed.
244+
245+
```typescript
246+
// api.ts
247+
export const addStrike = async (traineeId: string, strike: Strike) => {
248+
const strikeRequest = mapDomainToStrikeRequest(strike);
249+
const { data } = await axios.post<StrikeResponse>(`/api/trainees/${traineeId}/strikes`, strikeRequest);
250+
return mapStrikeToDomain(data);
251+
};
252+
```
253+
254+
And to use it in the component:
255+
256+
```typescript
257+
import { useAddStrike } from './data/mutations';
258+
259+
const { mutate: addStrike, isPending } = useAddStrike(traineeId);
260+
261+
// Add a new strike
262+
addStrike(newStrike, {
263+
onSuccess: () => {
264+
// Optionally update UI or show a success message
265+
},
266+
onError: (error) => {
267+
// Handle error
268+
},
269+
});
270+
```
271+
272+
> **Note:** On success, the mutation will invalidate the relevant query so the UI stays in sync with the server.
273+
> This structure keeps your API logic, data fetching, and UI code clean and maintainable. Use this pattern for new features!

client/src/data/tanstack/tanstackClient.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,18 @@ export const resetNavigateTo = () => {
4343
navigateTo = null;
4444
};
4545

46+
const handleError = (error: unknown) => {
47+
const axiosError = error as AxiosError | undefined;
48+
// If the backend returned 401, redirect to login using the stored navigate.
49+
// If `navigateTo` is not set (e.g. during tests), this is a no-op.
50+
if (axiosError?.response?.status === 401) {
51+
navigateTo?.('/login', { replace: true });
52+
}
53+
};
54+
4655
// Centralized QueryCache. Global error handling happens here.
4756
const queryCache = new QueryCache({
48-
onError(error: unknown) {
49-
const axiosError = error as AxiosError | undefined;
50-
51-
// If the backend returned 401, redirect to login using the stored navigate.
52-
// If `navigateTo` is not set (e.g. during tests), this is a no-op.
53-
if (axiosError?.response?.status === 401) {
54-
navigateTo?.('/login', { replace: true });
55-
}
56-
},
57+
onError: handleError,
5758
});
5859

5960
// App-wide QueryClient using the cache above. Import and pass this to
@@ -62,6 +63,6 @@ export const queryClient = new QueryClient({
6263
queryCache,
6364
defaultOptions: {
6465
queries: { retry: 1 },
65-
mutations: { retry: 1 },
66+
mutations: { retry: 1, onError: handleError },
6667
},
6768
});

client/src/data/types/Trainee.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,6 @@ export enum LearningStatus {
5353
Quit = 'quit',
5454
}
5555

56-
export enum StrikeReason {
57-
LateSubmission = 'late-submission',
58-
MissedSubmission = 'missed-submission',
59-
IncompleteSubmission = 'incomplete-submission',
60-
LateAttendance = 'late-attendance',
61-
Absence = 'absence',
62-
PendingFeedback = 'pending-feedback',
63-
Other = 'other',
64-
}
65-
6656
export enum QuitReason {
6757
Technical = 'technical',
6858
SocialSkills = 'social-skills',
@@ -180,15 +170,6 @@ export interface TraineeEmploymentInfo {
180170
comments?: string;
181171
}
182172

183-
export interface Strike {
184-
readonly id: string;
185-
date: Date;
186-
reporterID: string;
187-
reason: StrikeReason | null;
188-
comments: string;
189-
reporter: ReporterWithId;
190-
}
191-
192173
export interface Assignment {
193174
readonly id: string;
194175
createDate: Date;

client/src/features/trainee-profile/education/strikes/StrikeDetailsModal.tsx

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ import {
1414
TextField,
1515
Typography,
1616
} from '@mui/material';
17-
import { Strike, StrikeReason } from '../../../../data/types/Trainee';
17+
import { Strike, StrikeReason } from './models/strike';
1818

1919
import { LoadingButton } from '@mui/lab';
2020
import { formatDate } from '../../utils/dateHelper';
2121
import { useState } from 'react';
2222

23+
type StrikeInput = Omit<Strike, 'reporterName' | 'reporterImageUrl' | 'reason'> & {
24+
reason: StrikeReason | null; //optional for form handling
25+
};
26+
2327
interface StrikeDetailsModalProps {
2428
isOpen: boolean;
2529
error: string;
@@ -39,13 +43,12 @@ export const StrikeDetailsModal = ({
3943
onConfirmEdit,
4044
initialStrike,
4145
}: StrikeDetailsModalProps) => {
42-
const [strikeFields, setStrikeFields] = useState<Strike>({
46+
const [strikeFields, setStrikeFields] = useState<StrikeInput>({
4347
id: initialStrike?.id || '',
44-
date: initialStrike?.date || new Date(),
45-
reporterID: initialStrike?.reporterID || '',
48+
date: initialStrike ? new Date(initialStrike?.date) : new Date(),
4649
comments: initialStrike?.comments || '',
4750
reason: initialStrike?.reason || null,
48-
} as Strike);
51+
});
4952

5053
const [commentsRequiredError, setCommentsRequiredError] = useState<boolean>(false);
5154
const [reasonRequiredError, setReasonRequiredError] = useState<boolean>(false);
@@ -57,7 +60,7 @@ export const StrikeDetailsModal = ({
5760
const handleStrikeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
5861
const { name, value } = e.target;
5962

60-
setStrikeFields((prevStrike: Strike) => ({
63+
setStrikeFields((prevStrike: StrikeInput) => ({
6164
...prevStrike,
6265
[name]: name === 'date' ? new Date(value) : value,
6366
}));
@@ -66,12 +69,10 @@ export const StrikeDetailsModal = ({
6669
const handleStrikeSelectChange = (event: SelectChangeEvent<string>) => {
6770
const { name, value } = event.target;
6871
setReasonRequiredError(false);
69-
setStrikeFields(
70-
(prevStrike: Strike): Strike => ({
71-
...prevStrike,
72-
[name]: value,
73-
})
74-
);
72+
setStrikeFields((prevStrike: StrikeInput) => ({
73+
...prevStrike,
74+
[name]: value,
75+
}));
7576
};
7677

7778
const onConfirm = async () => {
@@ -84,10 +85,17 @@ export const StrikeDetailsModal = ({
8485
return;
8586
}
8687

88+
const strikeToSave: Strike = {
89+
id: strikeFields.id,
90+
date: strikeFields.date,
91+
reason: strikeFields.reason!,
92+
comments: strikeFields.comments,
93+
};
94+
8795
if (initialStrike) {
88-
onConfirmEdit(strikeFields);
96+
onConfirmEdit(strikeToSave);
8997
} else {
90-
onConfirmAdd(strikeFields);
98+
onConfirmAdd(strikeToSave);
9199
}
92100
};
93101

@@ -96,7 +104,7 @@ export const StrikeDetailsModal = ({
96104
setCommentsRequiredError(false);
97105
const { name, value } = e.target;
98106

99-
setStrikeFields((prevStrike) => ({
107+
setStrikeFields((prevStrike: StrikeInput) => ({
100108
...prevStrike,
101109
[name]: value,
102110
}));

0 commit comments

Comments
 (0)