From 0aa221ed13c15a108fa2a2a49437870549e3f27b Mon Sep 17 00:00:00 2001 From: Sten Levasseur Date: Sat, 11 Jan 2025 01:47:42 +0100 Subject: [PATCH 1/5] feat(server): update exercise category endpoints for ts-rest --- apps/api/src/main.ts | 16 +- .../modules/exercise/exercise.controller.ts | 2 - .../exerciseCategory.controller.ts | 161 ++++++++++++++---- .../exerciseCategory/exerciseCategory.dto.ts | 3 - .../exerciseCategory.service.ts | 92 ++++++++-- 5 files changed, 220 insertions(+), 54 deletions(-) delete mode 100644 apps/api/src/modules/exerciseCategory/exerciseCategory.dto.ts diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 4f00532..df66bc3 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,8 +1,16 @@ -import { NestFactory } from "@nestjs/core"; -import { AppModule } from "./app.module"; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT || 3001); + const app = await NestFactory.create(AppModule); + + // Activer CORS + app.enableCors({ + origin: 'http://localhost:3000', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + credentials: true, + }); + + await app.listen(process.env.PORT || 3001); } bootstrap(); diff --git a/apps/api/src/modules/exercise/exercise.controller.ts b/apps/api/src/modules/exercise/exercise.controller.ts index 40f6662..7a9cf29 100644 --- a/apps/api/src/modules/exercise/exercise.controller.ts +++ b/apps/api/src/modules/exercise/exercise.controller.ts @@ -46,8 +46,6 @@ export class ExerciseController implements NestControllerInterface { @TsRest(c.getExercise) async getExercise(@TsRestRequest() { params }: RequestShapes['getExercise']) { - // Dans le contrat, pathParams = { id: z.string() } - // => on cast en number (ou on utilise z.coerce.number() dans le contrat) try { const exercise = await this.exerciseService.getExercise(params.id); diff --git a/apps/api/src/modules/exerciseCategory/exerciseCategory.controller.ts b/apps/api/src/modules/exerciseCategory/exerciseCategory.controller.ts index 1a16517..f15cc51 100644 --- a/apps/api/src/modules/exerciseCategory/exerciseCategory.controller.ts +++ b/apps/api/src/modules/exerciseCategory/exerciseCategory.controller.ts @@ -1,51 +1,152 @@ +import { exerciseCategoryContract } from '@dropit/contract'; import { - Body, + BadRequestException, Controller, - Delete, - Get, - Param, - Post, - Put, + NotFoundException, } from '@nestjs/common'; -import { ExerciseCategory } from '../../entities/exerciseCategory.entity'; +import { + NestControllerInterface, + NestRequestShapes, + TsRest, + TsRestRequest, + nestControllerContract, +} from '@ts-rest/nest'; import { ExerciseCategoryService } from './exerciseCategory.service'; -@Controller('exercise-category') -export class ExerciseCategoryController { +const c = nestControllerContract(exerciseCategoryContract); +type RequestShapes = NestRequestShapes; + +@Controller() +export class ExerciseCategoryController + implements NestControllerInterface +{ constructor( private readonly exerciseCategoryService: ExerciseCategoryService ) {} - @Get() - async getExerciseCategories() { - return this.exerciseCategoryService.getExerciseCategories(); + @TsRest(c.getExerciseCategories) + async getExerciseCategories( + @TsRestRequest() request: RequestShapes['getExerciseCategories'] + ) { + try { + const exerciseCategories = + await this.exerciseCategoryService.getExerciseCategories(); + return { + status: 200 as const, + body: exerciseCategories, + }; + } catch (error) { + if (error instanceof NotFoundException) { + return { + status: 404 as const, + body: { + message: error.message, + }, + }; + } + throw error; + } } - @Get(':id') - async getExerciseCategory(@Param('id') id: string) { - return this.exerciseCategoryService.getExerciseCategory(id); + @TsRest(c.getExerciseCategory) + async getExerciseCategory( + @TsRestRequest() + { params }: RequestShapes['getExerciseCategory'] + ) { + try { + const exerciseCategory = + await this.exerciseCategoryService.getExerciseCategory(params.id); + return { + status: 200 as const, + body: exerciseCategory, + }; + } catch (error) { + if (error instanceof NotFoundException) { + return { + status: 404 as const, + body: { + message: error.message, + }, + }; + } + throw error; + } } - @Post() - async createExerciseCategory(@Body() exerciseCategory: ExerciseCategory) { - return this.exerciseCategoryService.createExerciseCategory( - exerciseCategory - ); + @TsRest(c.createExerciseCategory) + async createExerciseCategory( + @TsRestRequest() { body }: RequestShapes['createExerciseCategory'] + ) { + try { + const newExerciseCategory = + await this.exerciseCategoryService.createExerciseCategory(body); + return { + status: 201 as const, + body: newExerciseCategory, + }; + } catch (error) { + if (error instanceof BadRequestException) { + return { + status: 400 as const, + body: { + message: error.message, + }, + }; + } + throw error; + } } - @Put(':id') + @TsRest(c.updateExerciseCategory) async updateExerciseCategory( - @Param('id') id: string, - @Body() exerciseCategory: ExerciseCategory + @TsRestRequest() { params, body }: RequestShapes['updateExerciseCategory'] ) { - return this.exerciseCategoryService.updateExerciseCategory( - id, - exerciseCategory - ); + try { + const updatedExerciseCategory = + await this.exerciseCategoryService.updateExerciseCategory( + params.id, + body + ); + return { + status: 200 as const, + body: updatedExerciseCategory, + }; + } catch (error) { + if (error instanceof NotFoundException) { + return { + status: 404 as const, + body: { + message: error.message, + }, + }; + } + throw error; + } } - @Delete(':id') - async deleteExerciseCategory(@Param('id') id: string) { - return this.exerciseCategoryService.deleteExerciseCategory(id); + @TsRest(c.deleteExerciseCategory) + async deleteExerciseCategory( + @TsRestRequest() { params }: RequestShapes['deleteExerciseCategory'] + ) { + try { + await this.exerciseCategoryService.deleteExerciseCategory(params.id); + + return { + status: 200 as const, + body: { + message: 'Exercise category deleted successfully', + }, + }; + } catch (error) { + if (error instanceof NotFoundException) { + return { + status: 404 as const, + body: { + message: error.message, + }, + }; + } + throw error; + } } } diff --git a/apps/api/src/modules/exerciseCategory/exerciseCategory.dto.ts b/apps/api/src/modules/exerciseCategory/exerciseCategory.dto.ts deleted file mode 100644 index 35f82c8..0000000 --- a/apps/api/src/modules/exerciseCategory/exerciseCategory.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type ExerciseCategoryDto = { - name: string; -}; diff --git a/apps/api/src/modules/exerciseCategory/exerciseCategory.service.ts b/apps/api/src/modules/exerciseCategory/exerciseCategory.service.ts index 7537c16..b25f2f2 100644 --- a/apps/api/src/modules/exerciseCategory/exerciseCategory.service.ts +++ b/apps/api/src/modules/exerciseCategory/exerciseCategory.service.ts @@ -1,31 +1,73 @@ +import { + CreateExerciseCategory, + ExerciseCategoryDto, + UpdateExerciseCategory, +} from '@dropit/schemas'; import { EntityManager, wrap } from '@mikro-orm/postgresql'; -import { Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { ExerciseCategory } from '../../entities/exerciseCategory.entity'; -import { ExerciseCategoryDto } from './exerciseCategory.dto'; @Injectable() export class ExerciseCategoryService { constructor(private readonly em: EntityManager) {} - async getExerciseCategories() { - return this.em.find(ExerciseCategory, {}); + async getExerciseCategories(): Promise { + const exerciseCategories = await this.em.find(ExerciseCategory, {}); + + return exerciseCategories.map((exerciseCategory) => ({ + id: exerciseCategory.id, + name: exerciseCategory.name, + })); } - async getExerciseCategory(id: string) { - return this.em.findOne(ExerciseCategory, { id }); + async getExerciseCategory(id: string): Promise { + const exerciseCategory = await this.em.findOne(ExerciseCategory, { id }); + + if (!exerciseCategory) { + throw new NotFoundException(`Exercise category with ID ${id} not found`); + } + + return { + id: exerciseCategory.id, + name: exerciseCategory.name, + }; } - async createExerciseCategory(exerciseCategory: ExerciseCategoryDto) { + async createExerciseCategory( + newExerciseCategory: CreateExerciseCategory + ): Promise { + if (!newExerciseCategory.name) { + throw new BadRequestException('Exercise category name is required'); + } + const exerciseCategoryToCreate = new ExerciseCategory(); - exerciseCategoryToCreate.name = exerciseCategory.name; + exerciseCategoryToCreate.name = newExerciseCategory.name; await this.em.persistAndFlush(exerciseCategoryToCreate); - return exerciseCategoryToCreate; + + const exerciseCategoryCreated = await this.em.findOne(ExerciseCategory, { + id: exerciseCategoryToCreate.id, + }); + + if (!exerciseCategoryCreated) { + throw new NotFoundException( + `Exercise category with ID ${exerciseCategoryToCreate.id} not found` + ); + } + + return { + id: exerciseCategoryCreated.id, + name: exerciseCategoryCreated.name, + }; } async updateExerciseCategory( id: string, - exerciseCategory: ExerciseCategoryDto - ) { + exerciseCategory: UpdateExerciseCategory + ): Promise { const exerciseCategoryToUpdate = await this.em.findOne(ExerciseCategory, { id, }); @@ -34,21 +76,41 @@ export class ExerciseCategoryService { throw new Error('Exercise category not found'); } - wrap(exerciseCategoryToUpdate).assign(exerciseCategory); + wrap(exerciseCategoryToUpdate).assign(exerciseCategory, { + mergeObjectProperties: true, + }); await this.em.persistAndFlush(exerciseCategoryToUpdate); - return exerciseCategoryToUpdate; + + const exerciseCategoryUpdated = await this.em.findOne(ExerciseCategory, { + id: exerciseCategoryToUpdate.id, + }); + + if (!exerciseCategoryUpdated) { + throw new NotFoundException( + `Exercise category with ID ${exerciseCategoryToUpdate.id} not found` + ); + } + + return { + id: exerciseCategoryUpdated.id, + name: exerciseCategoryUpdated.name, + }; } - async deleteExerciseCategory(id: string) { + async deleteExerciseCategory(id: string): Promise<{ message: string }> { const exerciseCategoryToDelete = await this.em.findOne(ExerciseCategory, { id, }); if (!exerciseCategoryToDelete) { - throw new Error('Exercise category not found'); + throw new NotFoundException(`Exercise category with ID ${id} not found`); } await this.em.removeAndFlush(exerciseCategoryToDelete); + + return { + message: 'Exercise category deleted successfully', + }; } } From 8fd7594f9abcc1c96ad8ec3c711ccdcd6a980b6c Mon Sep 17 00:00:00 2001 From: Sten Levasseur Date: Sat, 11 Jan 2025 01:48:32 +0100 Subject: [PATCH 2/5] feat(packages): add exercise category contract and schemas --- .../src/exercise-category.contract.ts | 99 +++++++++++++++++++ packages/contract/src/exercise.contract.ts | 6 +- packages/contract/src/index.ts | 12 +++ .../schemas/src/exercise-category.schema.ts | 22 +++++ packages/schemas/src/index.ts | 1 + 5 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 packages/contract/src/exercise-category.contract.ts create mode 100644 packages/schemas/src/exercise-category.schema.ts diff --git a/packages/contract/src/exercise-category.contract.ts b/packages/contract/src/exercise-category.contract.ts new file mode 100644 index 0000000..dca9614 --- /dev/null +++ b/packages/contract/src/exercise-category.contract.ts @@ -0,0 +1,99 @@ +import { + createExerciseCategorySchema, + exerciseCategorySchema, + updateExerciseCategorySchema, +} from '@dropit/schemas'; +import { z } from 'zod'; + +export const exerciseCategoryContract = { + getExerciseCategories: { + method: 'GET', + path: '/exercise-category', + summary: 'Get all exercise categories', + responses: { + 200: z.array(exerciseCategorySchema), + 404: z.object({ + message: z.string(), + }), + 500: z.object({ + message: z.string(), + }), + }, + }, + + getExerciseCategory: { + method: 'GET', + path: '/exercise-category/:id', + summary: 'Get an exercise category by id', + pathParams: z.object({ + id: z.string(), + }), + responses: { + 200: exerciseCategorySchema, + 404: z.object({ + message: z.string(), + }), + 500: z.object({ + message: z.string(), + }), + }, + }, + + createExerciseCategory: { + method: 'POST', + path: '/exercise-category', + summary: 'Create an exercise category', + body: createExerciseCategorySchema, + responses: { + 201: exerciseCategorySchema, + 400: z.object({ + message: z.string(), + }), + 404: z.object({ + message: z.string(), + }), + 500: z.object({ + message: z.string(), + }), + }, + }, + + updateExerciseCategory: { + method: 'PATCH', + path: '/exercise-category/:id', + summary: 'Update an exercise category', + pathParams: z.object({ + id: z.string(), + }), + body: updateExerciseCategorySchema, + responses: { + 200: exerciseCategorySchema, + 404: z.object({ + message: z.string(), + }), + 500: z.object({ + message: z.string(), + }), + }, + }, + + deleteExerciseCategory: { + method: 'DELETE', + path: '/exercise-category/:id', + summary: 'Delete an exercise category', + pathParams: z.object({ + id: z.string(), + }), + responses: { + 200: z.object({ + message: z.string(), + }), + 404: z.object({ + message: z.string(), + }), + 500: z.object({ + message: z.string(), + }), + }, + }, +} as const; diff --git a/packages/contract/src/exercise.contract.ts b/packages/contract/src/exercise.contract.ts index 9fcd099..82e5dc7 100644 --- a/packages/contract/src/exercise.contract.ts +++ b/packages/contract/src/exercise.contract.ts @@ -3,11 +3,9 @@ import { exerciseSchema, updateExerciseSchema, } from '@dropit/schemas'; -import { initContract } from '@ts-rest/core'; import { z } from 'zod'; -const c = initContract(); -export const exerciseContract = c.router({ +export const exerciseContract = { getExercises: { method: 'GET', path: '/exercise', @@ -116,4 +114,4 @@ export const exerciseContract = c.router({ }), }, }, -}); +} as const; diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 7f77822..03afe3f 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -1 +1,13 @@ +import { initContract } from '@ts-rest/core'; +import { exerciseCategoryContract } from './exercise-category.contract'; +import { exerciseContract } from './exercise.contract'; + +const c = initContract(); + +export const apiContract = c.router({ + exercise: exerciseContract, + exerciseCategory: exerciseCategoryContract, +}); + export * from './exercise.contract'; +export * from './exercise-category.contract'; diff --git a/packages/schemas/src/exercise-category.schema.ts b/packages/schemas/src/exercise-category.schema.ts new file mode 100644 index 0000000..9259478 --- /dev/null +++ b/packages/schemas/src/exercise-category.schema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const createExerciseCategorySchema = z.object({ + name: z.string(), +}); + +export type CreateExerciseCategory = z.infer< + typeof createExerciseCategorySchema +>; + +export const updateExerciseCategorySchema = createExerciseCategorySchema; + +export type UpdateExerciseCategory = z.infer< + typeof updateExerciseCategorySchema +>; + +export const exerciseCategorySchema = z.object({ + id: z.string(), + name: z.string(), +}); + +export type ExerciseCategoryDto = z.infer; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 778ec09..1784ce5 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -1 +1,2 @@ export * from './exercice.schema'; +export * from './exercise-category.schema'; From 3ff508aa632f3fb6c98589b04ae1f79af365684b Mon Sep 17 00:00:00 2001 From: Sten Levasseur Date: Sat, 11 Jan 2025 01:49:18 +0100 Subject: [PATCH 3/5] feat(client): add exercise creation form --- apps/web/package.json | 8 +- .../exercises/exercise-creation-form.tsx | 281 ++++++++++++------ apps/web/src/components/ui/file-upload.tsx | 166 ++++++----- apps/web/src/components/ui/form.tsx | 176 +++++++++++ apps/web/src/components/ui/toast.tsx | 127 ++++++++ apps/web/src/components/ui/toaster.tsx | 33 ++ apps/web/src/hooks/use-toast.ts | 194 ++++++++++++ apps/web/src/lib/api.ts | 4 +- pnpm-lock.yaml | 64 ++++ 9 files changed, 876 insertions(+), 177 deletions(-) create mode 100644 apps/web/src/components/ui/form.tsx create mode 100644 apps/web/src/components/ui/toast.tsx create mode 100644 apps/web/src/components/ui/toaster.tsx create mode 100644 apps/web/src/hooks/use-toast.ts diff --git a/apps/web/package.json b/apps/web/package.json index f035c03..85d2e19 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,8 +9,9 @@ "preview": "vite preview" }, "dependencies": { - "@dropit/schemas": "workspace:*", "@dropit/contract": "workspace:*", + "@dropit/schemas": "workspace:*", + "@hookform/resolvers": "^3.10.0", "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", @@ -18,6 +19,7 @@ "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-toast": "^1.2.4", "@tanstack/react-query": "^5.62.7", "@tanstack/react-router": "^1.89.0", "@tanstack/react-table": "^8.20.6", @@ -27,9 +29,11 @@ "lucide-react": "^0.469.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.54.2", "tailwind-merge": "^2.5.5", "tailwindcss": "^3.0.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.1" }, "devDependencies": { "@tanstack/router-devtools": "^1.95.0", diff --git a/apps/web/src/components/exercises/exercise-creation-form.tsx b/apps/web/src/components/exercises/exercise-creation-form.tsx index f85fa2e..8f97e6c 100644 --- a/apps/web/src/components/exercises/exercise-creation-form.tsx +++ b/apps/web/src/components/exercises/exercise-creation-form.tsx @@ -1,10 +1,22 @@ +import { useToast } from '@/hooks/use-toast'; import { api } from '@/lib/api'; -import { CreateExercise } from '@dropit/schemas'; +import { CreateExercise, createExerciseSchema } from '@dropit/schemas'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; import { Button } from '../ui/button'; import { FileUpload } from '../ui/file-upload'; +import { + Form, + FormControl, + FormField, + FormLabel, + FormMessage, +} from '../ui/form'; +import { FormItem } from '../ui/form'; import { Input } from '../ui/input'; -import { Label } from '../ui/label'; import { Select, SelectContent, @@ -14,126 +26,197 @@ import { } from '../ui/select'; import { Textarea } from '../ui/textarea'; -interface ExerciseCreationFormProps { +type ExerciseCreationFormProps = { onSuccess?: () => void; onCancel?: () => void; -} +}; export function ExerciseCreationForm({ onSuccess, onCancel, }: ExerciseCreationFormProps) { const [isLoading, setIsLoading] = useState(false); - const [formData, setFormData] = useState>({}); + const { toast } = useToast(); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); + const { data: exerciseCategories, isLoading: categoriesLoading } = useQuery({ + queryKey: ['exercise-categories'], + queryFn: async () => { + const response = await api.exerciseCategory.getExerciseCategories(); + if (response.status !== 200) throw new Error('Failed to load categories'); + return response.body; + }, + }); - try { - const response = await api.createExercise({ - body: { - // biome-ignore lint/style/noNonNullAssertion: - name: formData.name!, - // biome-ignore lint/style/noNonNullAssertion: - exerciseCategory: formData.exerciseCategory!, - description: formData.description, - englishName: formData.englishName, - shortName: formData.shortName, - // La gestion de la vidéo nécessitera probablement une route séparée pour l'upload - }, + const { mutate: createExerciseMutation } = useMutation({ + mutationFn: async (data: CreateExercise) => { + const response = await api.exercise.createExercise({ body: data }); + if (response.status !== 201) { + throw new Error("Erreur lors de la création de l'exercice"); + } + return response.body; + }, + onSuccess: () => { + toast({ + title: 'Exercice créé avec succès', + description: "L'exercice a été créé avec succès", }); + onSuccess?.(); + }, + onError: (error) => { + toast({ + title: 'Erreur', + description: error.message, + variant: 'destructive', + }); + }, + }); - if (response.status === 201) { - onSuccess?.(); - } - } catch (error) { - console.error('Erreur lors de la création:', error); + const handleSubmit = (formValues: z.infer) => { + setIsLoading(true); + + try { + createExerciseMutation(formValues); } finally { setIsLoading(false); } }; - const handleChange = (field: keyof CreateExercise, value: string) => { - setFormData((prev) => ({ ...prev, [field]: value })); - }; + const formExerciseSchema = createExerciseSchema; + const form = useForm>({ + resolver: zodResolver(formExerciseSchema), + defaultValues: { + name: '', + exerciseCategory: undefined, + description: '', + englishName: '', + shortName: '', + video: undefined, + }, + }); return ( -
-
- - handleChange('name', e.target.value)} - required + + + ( + + Nom + + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} /> -
-
- - -
-
- -