Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"cSpell.words": [
"Docxtemplater",
"Gotenberg"
]
],
}
181 changes: 175 additions & 6 deletions client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ Frontend application for HackYourFuture's trainee management system
![Vite](https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white)
![MUI](https://img.shields.io/badge/MUI-%23007FFF.svg?style=for-the-badge&logo=mui&logoColor=white)


## Technology Stack

- **React** - Modern React with hooks and concurrent features
- **TypeScript**
- **Vite** - For development server
Expand All @@ -26,16 +26,18 @@ Before you begin, ensure you have the following installed:
## Setup

1. **Install dependencies**:

```bash
npm run setup
```

2. **Set up environment variables**:
Create a `.env` file in the client directory:

```bash
# Backend API URL (optional - defaults to http://localhost:7777)
VITE_BACKEND_PROXY_TARGET=http://localhost:7777

# Google OAuth Client ID (required for authentication)
VITE_GOOGLE_CLIENT_ID=your_google_client_id_here
```
Expand All @@ -62,20 +64,23 @@ The build files will be generated in the `dist/` directory.

### Environment Variables

| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `VITE_BACKEND_PROXY_TARGET` | Backend API URL | `http://localhost:7777` | No |
| `VITE_GOOGLE_CLIENT_ID` | Google OAuth Client ID | - | Yes |
| Variable | Description | Default | Required |
| --------------------------- | ---------------------- | ----------------------- | -------- |
| `VITE_BACKEND_PROXY_TARGET` | Backend API URL | `http://localhost:7777` | No |
| `VITE_GOOGLE_CLIENT_ID` | Google OAuth Client ID | - | Yes |

### API Proxy Configuration

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/)
The development server automatically proxies API requests:

- `/api/*` → Backend server
- `/api-docs/*` → Backend API documentation

This eliminates CORS issues during development.

## 🗂️ Client Structure

- `src/`: Contains all React components and application logic.
- `assets/`: Contains all the assets and images used.
- `components/`: Reusable UI components.
Expand All @@ -98,7 +103,171 @@ Make sure you have `VITE_GOOGLE_CLIENT_ID` set up correctly. Check out the serve
## API Integration

The client communicates with the backend API through:

- **Axios** for HTTP requests
- **React Query** for caching and state management
- **Automatic retry** for failed requests
- **Optimistic updates** for better UX

---

## Using TanStack: Example with Strikes Feature (wip)

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:

### File Structure for API and Data Layers

```
strikes/
├── api/
│ ├── api.ts # API calls (fetch, add, edit, delete)
│ ├── mapper.ts # Maps API types to domain models
│ ├── types.ts # API request/response types
├── data/
│ ├── keys.ts # Query key factory for React Query
│ ├── mutations.ts # React Query mutation hooks
│ ├── strike-queries.ts # React Query query hooks
```

- `api.ts`: clean calls using axios
- `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.
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.
For example, you might convert snake_case fields to camelCase, filter out unused properties, or maybe flatten nested data/
- `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.

-`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.
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.

- `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.
- `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.

### Example: Fetching Strikes with useQuery

`data/strike-queries.ts`:

```typescript
import { useQuery } from '@tanstack/react-query';
import { getStrikes } from '../api/api';
import { strikeKeys } from './keys';

export const useGetStrikes = (traineeId: string) =>
useQuery({
queryKey: strikeKeys.list(traineeId),
queryFn: () => getStrikes(traineeId),
});
```

`data/keys.ts`:

```typescript
export const strikeKeys = {
all: ['strikes'],
list: (traineeId: string) => [...strikeKeys.all, 'list', traineeId],
};
```

💡 **Note:** This function creates two query keys for us.

1. all: the key looks like this: ['strikes']
2. list: the key looks like this: ['strikes', 'list', `traineeId`].
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 :)

`api/api.ts`:

```typescript
import axios from 'axios';
import { StrikeResponse } from './types';
import { mapStrikeToDomain } from './mapper';

export const getStrikes = async (traineeId: string) => {
const { data } = await axios.get<StrikeResponse[]>(`/api/trainees/${traineeId}/strikes`);
return data.map((strike) => mapStrikeToDomain(strike));
};
```

As you can see, the reporter details we get from the backend are nested.

```typescript
export interface StrikeResponse {
id: string;
comments: string;
date: string; // ISO string from backend
reason: StrikeReason;
reporter: ReporterDTO;
}

interface ReporterDTO {
id: string;
name: string;
imageUrl?: string;
}
```

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.

```typescript
// models/strike.ts
export interface Strike {
id: string;
comments: string;
date: Date;
reason: StrikeReason;
reporterName?: string;
reporterImageUrl?: string;
}
```

### Using the Hook in a Component

```typescript
const { data: strikes, isPending } = useGetStrikes(traineeId);
```

### Example: Mutating Strikes Data

To add, edit, or delete a strike, use a mutation hook from `data/mutations.ts`:

```typescript
export const useAddStrike = (traineeId: string) => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (strike: Strike) => {
return addStrike(traineeId, strike);
},
onSuccess: async () => await invalidateStrikesQuery(queryClient, traineeId),
});
};
```

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.

```typescript
// api.ts
export const addStrike = async (traineeId: string, strike: Strike) => {
const strikeRequest = mapDomainToStrikeRequest(strike);
const { data } = await axios.post<StrikeResponse>(`/api/trainees/${traineeId}/strikes`, strikeRequest);
return mapStrikeToDomain(data);
};
```

And to use it in the component:

```typescript
import { useAddStrike } from './data/mutations';

const { mutate: addStrike, isPending } = useAddStrike(traineeId);

// Add a new strike
addStrike(newStrike, {
onSuccess: () => {
// Optionally update UI or show a success message
},
onError: (error) => {
// Handle error
},
});
```

> **Note:** On success, the mutation will invalidate the relevant query so the UI stays in sync with the server.
> This structure keeps your API logic, data fetching, and UI code clean and maintainable. Use this pattern for new features!
21 changes: 11 additions & 10 deletions client/src/data/tanstack/tanstackClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,18 @@ export const resetNavigateTo = () => {
navigateTo = null;
};

const handleError = (error: unknown) => {
const axiosError = error as AxiosError | undefined;
// If the backend returned 401, redirect to login using the stored navigate.
// If `navigateTo` is not set (e.g. during tests), this is a no-op.
if (axiosError?.response?.status === 401) {
navigateTo?.('/login', { replace: true });
}
};

// Centralized QueryCache. Global error handling happens here.
const queryCache = new QueryCache({
onError(error: unknown) {
const axiosError = error as AxiosError | undefined;

// If the backend returned 401, redirect to login using the stored navigate.
// If `navigateTo` is not set (e.g. during tests), this is a no-op.
if (axiosError?.response?.status === 401) {
navigateTo?.('/login', { replace: true });
}
},
onError: handleError,
});

// App-wide QueryClient using the cache above. Import and pass this to
Expand All @@ -62,6 +63,6 @@ export const queryClient = new QueryClient({
queryCache,
defaultOptions: {
queries: { retry: 1 },
mutations: { retry: 1 },
mutations: { retry: 1, onError: handleError },
},
});
19 changes: 0 additions & 19 deletions client/src/data/types/Trainee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,6 @@ export enum LearningStatus {
Quit = 'quit',
}

export enum StrikeReason {
LateSubmission = 'late-submission',
MissedSubmission = 'missed-submission',
IncompleteSubmission = 'incomplete-submission',
LateAttendance = 'late-attendance',
Absence = 'absence',
PendingFeedback = 'pending-feedback',
Other = 'other',
}

export enum QuitReason {
Technical = 'technical',
SocialSkills = 'social-skills',
Expand Down Expand Up @@ -180,15 +170,6 @@ export interface TraineeEmploymentInfo {
comments?: string;
}

export interface Strike {
readonly id: string;
date: Date;
reporterID: string;
reason: StrikeReason | null;
comments: string;
reporter: ReporterWithId;
}

export interface Assignment {
readonly id: string;
createDate: Date;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ import {
TextField,
Typography,
} from '@mui/material';
import { Strike, StrikeReason } from '../../../../data/types/Trainee';
import { Strike, StrikeReason } from './models/strike';

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

type StrikeInput = Omit<Strike, 'reporterName' | 'reporterImageUrl' | 'reason'> & {
reason: StrikeReason | null; //optional for form handling
};

interface StrikeDetailsModalProps {
isOpen: boolean;
error: string;
Expand All @@ -39,13 +43,12 @@ export const StrikeDetailsModal = ({
onConfirmEdit,
initialStrike,
}: StrikeDetailsModalProps) => {
const [strikeFields, setStrikeFields] = useState<Strike>({
const [strikeFields, setStrikeFields] = useState<StrikeInput>({
id: initialStrike?.id || '',
date: initialStrike?.date || new Date(),
reporterID: initialStrike?.reporterID || '',
date: initialStrike ? new Date(initialStrike?.date) : new Date(),
comments: initialStrike?.comments || '',
reason: initialStrike?.reason || null,
} as Strike);
});

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

setStrikeFields((prevStrike: Strike) => ({
setStrikeFields((prevStrike: StrikeInput) => ({
...prevStrike,
[name]: name === 'date' ? new Date(value) : value,
}));
Expand All @@ -66,12 +69,10 @@ export const StrikeDetailsModal = ({
const handleStrikeSelectChange = (event: SelectChangeEvent<string>) => {
const { name, value } = event.target;
setReasonRequiredError(false);
setStrikeFields(
(prevStrike: Strike): Strike => ({
...prevStrike,
[name]: value,
})
);
setStrikeFields((prevStrike: StrikeInput) => ({
...prevStrike,
[name]: value,
}));
};

const onConfirm = async () => {
Expand All @@ -84,10 +85,17 @@ export const StrikeDetailsModal = ({
return;
}

const strikeToSave: Strike = {
id: strikeFields.id,
date: strikeFields.date,
reason: strikeFields.reason!,
comments: strikeFields.comments,
};

if (initialStrike) {
onConfirmEdit(strikeFields);
onConfirmEdit(strikeToSave);
} else {
onConfirmAdd(strikeFields);
onConfirmAdd(strikeToSave);
}
};

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

setStrikeFields((prevStrike) => ({
setStrikeFields((prevStrike: StrikeInput) => ({
...prevStrike,
[name]: value,
}));
Expand Down
Loading