diff --git a/apps/api/src/features/routines/routine.routes.test.ts b/apps/api/src/features/routines/routine.routes.test.ts index 68c2d7f..a3ad7ec 100644 --- a/apps/api/src/features/routines/routine.routes.test.ts +++ b/apps/api/src/features/routines/routine.routes.test.ts @@ -86,8 +86,9 @@ describe('POST /routines', () => { exercises: [ { ...createRoutineInput.exercises[0], - restBetweenSetsSeconds: 0, + restBetweenSetsSeconds: 60, restAfterExerciseSeconds: 0, + notes: null, }, ], schedules: [], diff --git a/apps/api/src/features/routines/routine.service.test.ts b/apps/api/src/features/routines/routine.service.test.ts index a6c5e66..09de793 100644 --- a/apps/api/src/features/routines/routine.service.test.ts +++ b/apps/api/src/features/routines/routine.service.test.ts @@ -64,6 +64,7 @@ describe('createRoutineForUser', () => { weightUnit: 'lb', restBetweenSetsSeconds: 60, restAfterExerciseSeconds: 90, + notes: null, }, ], schedules: [{ dayOfWeek: 3, localTime: '18:30:00' }], @@ -128,6 +129,7 @@ describe('createRoutineForUser', () => { weightUnit: 'lb', restBetweenSetsSeconds: 0, restAfterExerciseSeconds: 0, + notes: null, }, ], schedules: [], diff --git a/apps/web/src/components/ui/number-stepper.tsx b/apps/web/src/components/ui/number-stepper.tsx index 759b76a..b3a1484 100644 --- a/apps/web/src/components/ui/number-stepper.tsx +++ b/apps/web/src/components/ui/number-stepper.tsx @@ -8,6 +8,7 @@ import { Button } from './button'; type NumberStepperProps = { defaultValue: number; id: string; + onValueChange: (value: number) => void; min?: number; }; @@ -15,20 +16,26 @@ export function NumberStepper({ defaultValue, id, min = 1, + onValueChange, }: NumberStepperProps) { const [value, setValue] = useState(defaultValue); const decreaseValue = () => { - setValue((currentValue) => Math.max(min, Number(currentValue || min) - 1)); + const nextValue = Math.max(min, Number(value || min) - 1); + setValue(nextValue); + onValueChange(nextValue); }; const increaseValue = () => { - setValue((currentValue) => Number(currentValue || min) + 1); + const nextValue = Number(value || min) + 1; + setValue(nextValue); + onValueChange(nextValue); }; const finishEditing = () => { if (value === '' || Number(value) < min) { setValue(min); + onValueChange(min); } }; @@ -62,6 +69,10 @@ export function NumberStepper({ if (nextValue === '' || /^\d+$/.test(nextValue)) { setValue(nextValue); + + if (nextValue !== '') { + onValueChange(Number(nextValue)); + } } }} type="number" diff --git a/apps/web/src/components/ui/positive-integer-input.tsx b/apps/web/src/components/ui/positive-integer-input.tsx index fab8697..26d855d 100644 --- a/apps/web/src/components/ui/positive-integer-input.tsx +++ b/apps/web/src/components/ui/positive-integer-input.tsx @@ -3,21 +3,24 @@ import { useState } from 'react'; import { Input } from './input'; type PositiveIntegerInputProps = { - className?: string; defaultValue: number; id: string; + onValueChange: (value: number) => void; + className?: string; }; export function PositiveIntegerInput({ defaultValue, id, className, + onValueChange, }: PositiveIntegerInputProps) { const [value, setValue] = useState(defaultValue); const finishEditing = () => { if (value === '' || Number(value) < 1) { setValue(1); + onValueChange(1); } }; @@ -33,6 +36,10 @@ export function PositiveIntegerInput({ if (nextValue === '' || /^\d+$/.test(nextValue)) { setValue(nextValue); + + if (nextValue !== '') { + onValueChange(Number(nextValue)); + } } }} type="number" diff --git a/apps/web/src/features/routines/exercise-configuration.test.tsx b/apps/web/src/features/routines/exercise-configuration.test.tsx index 56e4ae6..50ad605 100644 --- a/apps/web/src/features/routines/exercise-configuration.test.tsx +++ b/apps/web/src/features/routines/exercise-configuration.test.tsx @@ -3,13 +3,20 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createMemoryHistory, createRootRoute, + createRoute, createRouter, + Outlet, RouterProvider, } from '@tanstack/react-router'; import { cleanup, render, screen } from '@testing-library/react'; -import type { PropsWithChildren } from 'react'; +import userEvent from '@testing-library/user-event'; +import { useFormContext, useWatch } from 'react-hook-form'; import { afterEach, expect, it, vi } from 'vitest'; +import { + RoutineDraftFormProvider, + type RoutineDraftFormValues, +} from './routine-draft-form'; import { ExerciseConfiguration } from './exercise-configuration'; afterEach(() => { @@ -17,24 +24,50 @@ afterEach(() => { vi.unstubAllGlobals(); }); -function Wrapper({ children }: PropsWithChildren) { +function renderConfiguration(exerciseId: string) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - const rootRoute = createRootRoute({ component: () => children }); + const rootRoute = createRootRoute({ component: TestLayout }); + const configurationRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/configure', + component: () => , + }); + const editorRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/routines/new', + component: DraftExerciseCount, + }); const router = createRouter({ - history: createMemoryHistory({ initialEntries: ['/'] }), - routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/configure'] }), + routeTree: rootRoute.addChildren([configurationRoute, editorRoute]), }); - return ( + render( - + , + ); +} + +function TestLayout() { + return ( + + + ); } +function DraftExerciseCount() { + const { control } = useFormContext(); + const exercises = useWatch({ control, name: 'exercises' }); + + return

Saved exercises: {exercises.length}

; +} + it('shows the selected exercise and initial configuration fields', async () => { + const user = userEvent.setup(); const exerciseId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486'; vi.stubGlobal( 'fetch', @@ -52,12 +85,14 @@ it('shows the selected exercise and initial configuration fields', async () => { ), ); - render(, { - wrapper: Wrapper, - }); + renderConfiguration(exerciseId); expect(await screen.findByText('Bicep Curl')).toBeVisible(); expect(screen.getByLabelText('Sets')).toHaveValue(3); expect(screen.getByLabelText('Reps')).toHaveValue(8); - expect(screen.getByRole('button', { name: 'Done' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Done' })).toBeEnabled(); + + await user.click(screen.getByRole('button', { name: 'Done' })); + + expect(await screen.findByText('Saved exercises: 1')).toBeVisible(); }); diff --git a/apps/web/src/features/routines/exercise-configuration.tsx b/apps/web/src/features/routines/exercise-configuration.tsx index 783825e..9be171f 100644 --- a/apps/web/src/features/routines/exercise-configuration.tsx +++ b/apps/web/src/features/routines/exercise-configuration.tsx @@ -1,17 +1,34 @@ /** Presents the configuration shell for an exercise selected for a routine. */ -import type { ExerciseSummary } from '@goforlift/contracts'; -import { Link } from '@tanstack/react-router'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + createRoutineExerciseInputSchema, + type ExerciseSummary, +} from '@goforlift/contracts'; +import { Link, useNavigate } from '@tanstack/react-router'; import { Dumbbell, X } from 'lucide-react'; +import { + type UseFormRegister, + type UseFormSetValue, + useForm, + useFormContext, +} from 'react-hook-form'; import { cn } from '@/lib/utils'; -import { Button } from '../../components/ui/button'; -import { Label } from '../../components/ui/label'; -import { LoadingState } from '../../components/ui/loading-state'; -import { NumberStepper } from '../../components/ui/number-stepper'; -import { PageTitle } from '../../components/ui/page-title'; -import { PositiveIntegerInput } from '../../components/ui/positive-integer-input'; -import { useExercises } from '../exercises/exercises.query'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { LoadingState } from '@/components/ui/loading-state'; +import { NumberStepper } from '@/components/ui/number-stepper'; +import { PageTitle } from '@/components/ui/page-title'; +import { PositiveIntegerInput } from '@/components/ui/positive-integer-input'; +import { useExercises } from '@/features/exercises/exercises.query'; +import type { RoutineDraftFormValues } from './routine-draft-form'; +import { z } from 'zod'; + +type RoutineExerciseFormValues = z.input< + typeof createRoutineExerciseInputSchema +>; +type RoutineExerciseInput = z.output; type ExerciseConfigurationProps = { exerciseId: string; @@ -71,14 +88,46 @@ function ExerciseConfigurationForm({ }: { exercise: ExerciseSummary; }) { + const navigate = useNavigate(); + const { getValues, setValue: setDraftValue } = + useFormContext(); + const defaultValues = createRoutineExerciseInputSchema.parse({ + exerciseId: exercise.id, + position: getValues('exercises').length, + }); + const { + handleSubmit, + register, + setValue: setConfigurationValue, + } = useForm({ + defaultValues, + mode: 'onChange', + resolver: zodResolver(createRoutineExerciseInputSchema), + }); + + async function saveExercise(input: RoutineExerciseInput) { + setDraftValue('exercises', [...getValues('exercises'), input], { + shouldDirty: true, + shouldValidate: true, + }); + await navigate({ to: '/routines/new' }); + } + return ( -
+
void handleSubmit(saveExercise)(event)} + >
- +
-
+ ); } @@ -110,12 +159,32 @@ function ExerciseIdentity({ exercise }: { exercise: ExerciseSummary }) { ); } -function ExerciseFields() { +function ExerciseFields({ + defaultValues, + register, + setValue, +}: { + defaultValues: RoutineExerciseInput; + register: UseFormRegister; + setValue: UseFormSetValue; +}) { return (
- - + +
@@ -127,13 +196,17 @@ function ExerciseFields() { > + setValue('weight', value, { shouldValidate: true }) + } />