diff --git a/apps/web/src/features/exercises/exercises.api.test.ts b/apps/web/src/features/exercises/exercises.api.test.ts index 969adaf..3db30b8 100644 --- a/apps/web/src/features/exercises/exercises.api.test.ts +++ b/apps/web/src/features/exercises/exercises.api.test.ts @@ -1,7 +1,11 @@ /** Verifies exercise API status and shared-contract handling. */ import { afterEach, expect, it, vi } from 'vitest'; -import { ExercisesApiError, fetchExercises } from './exercises.api'; +import { + createExercise, + ExercisesApiError, + fetchExercises, +} from './exercises.api'; afterEach(() => { vi.unstubAllGlobals(); @@ -44,3 +48,37 @@ it('throws a typed error when the request fails', async () => { await expect(fetchExercises()).rejects.toEqual(new ExercisesApiError(500)); }); + +it('creates and validates a custom exercise', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ csrfToken: 'csrf-token' })) + .mockResolvedValueOnce( + Response.json( + { + id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + name: 'Cable Pullover', + description: 'Keep the arms straight.', + isCustom: true, + }, + { status: 201 }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect( + createExercise({ + description: 'Keep the arms straight.', + name: 'Cable Pullover', + }), + ).resolves.toMatchObject({ + name: 'Cable Pullover', + isCustom: true, + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/exercises', + expect.objectContaining({ method: 'POST' }), + ); +}); diff --git a/apps/web/src/features/exercises/exercises.api.ts b/apps/web/src/features/exercises/exercises.api.ts index 5259b52..4a2652c 100644 --- a/apps/web/src/features/exercises/exercises.api.ts +++ b/apps/web/src/features/exercises/exercises.api.ts @@ -1,9 +1,15 @@ /** Fetches and validates exercises available to the authenticated user. */ import { + createExerciseInputSchema, exerciseListResponseSchema, + exerciseSummarySchema, + type CreateExerciseInput, type ExerciseListResponse, + type ExerciseSummary, } from '@goforlift/contracts'; +import { requestWithCsrf } from '@/features/auth/auth.api'; + export class ExercisesApiError extends Error { constructor(public readonly status: number) { super(`Exercises API request failed with status ${status}`); @@ -26,3 +32,17 @@ export async function fetchExercises( const data: unknown = await response.json(); return exerciseListResponseSchema.parse(data); } + +export async function createExercise( + input: CreateExerciseInput, +): Promise { + const validatedInput = createExerciseInputSchema.parse(input); + const response = await requestWithCsrf('/api/exercises', { + body: JSON.stringify(validatedInput), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const data: unknown = await response.json(); + + return exerciseSummarySchema.parse(data); +} diff --git a/apps/web/src/features/exercises/exercises.mutation.ts b/apps/web/src/features/exercises/exercises.mutation.ts new file mode 100644 index 0000000..2bfbfd4 --- /dev/null +++ b/apps/web/src/features/exercises/exercises.mutation.ts @@ -0,0 +1,24 @@ +/** Creates custom exercises and synchronizes the exercise-list cache. */ +import type { ExerciseListResponse } from '@goforlift/contracts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { createExercise } from './exercises.api'; +import { exercisesQueryKey } from './exercises.query'; + +export function useCreateExerciseMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createExercise, + onSuccess: (createdExercise) => { + queryClient.setQueryData( + exercisesQueryKey, + (currentData) => ({ + exercises: currentData + ? [...currentData.exercises, createdExercise] + : [createdExercise], + }), + ); + }, + }); +} diff --git a/apps/web/src/features/routines/create/exercise-picker/custom-exercise-dialog.tsx b/apps/web/src/features/routines/create/exercise-picker/custom-exercise-dialog.tsx new file mode 100644 index 0000000..5448aea --- /dev/null +++ b/apps/web/src/features/routines/create/exercise-picker/custom-exercise-dialog.tsx @@ -0,0 +1,146 @@ +/** Creates a user-owned exercise without leaving the routine exercise picker. */ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + createExerciseInputSchema, + type CreateExerciseInput, +} from '@goforlift/contracts'; +import { useNavigate } from '@tanstack/react-router'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { AddActionButton } from '@/components/ui/add-action-button'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useCreateExerciseMutation } from '@/features/exercises/exercises.mutation'; +import { cn } from '@/lib/utils'; + +export function CustomExerciseDialog() { + const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); + const createExerciseMutation = useCreateExerciseMutation(); + const { + formState: { errors }, + handleSubmit, + register, + reset, + } = useForm({ + defaultValues: { description: null, name: '' }, + mode: 'onSubmit', + resolver: zodResolver(createExerciseInputSchema), + }); + + function handleOpenChange(open: boolean) { + setIsOpen(open); + + if (!open) { + reset(); + createExerciseMutation.reset(); + } + } + + async function saveCustomExercise(input: CreateExerciseInput) { + const exercise = await createExerciseMutation + .mutateAsync(input) + .catch(() => null); + + if (!exercise) { + return; + } + + handleOpenChange(false); + await navigate({ + params: { exerciseId: exercise.id }, + to: '/routines/new/exercises/$exerciseId', + }); + } + + return ( + + + + + + + + Create Custom Exercise + + Add an exercise that will only be available to you. + + +
void handleSubmit(saveCustomExercise)(event)} + > +
+ + + {errors.name && ( +

+ {errors.name.message} +

+ )} +
+ +
+ +