diff --git a/apps/web/package.json b/apps/web/package.json index 0ef5e1f..a241576 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,8 @@ "test": "vitest run" }, "dependencies": { + "@dnd-kit/helpers": "^0.5.0", + "@dnd-kit/react": "^0.5.0", "@goforlift/contracts": "workspace:*", "@hookform/resolvers": "^5.2.1", "@tanstack/react-query": "^5.85.5", diff --git a/apps/web/src/features/routines/api/routines.api.test.ts b/apps/web/src/features/routines/api/routines.api.test.ts index 61482fd..da55f33 100644 --- a/apps/web/src/features/routines/api/routines.api.test.ts +++ b/apps/web/src/features/routines/api/routines.api.test.ts @@ -1,7 +1,7 @@ /** Verifies routine requests include credentials and validate backend responses. */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchRoutines, RoutinesApiError } from './routines.api'; +import { createRoutine, fetchRoutines, RoutinesApiError } from './routines.api'; afterEach(() => vi.unstubAllGlobals()); @@ -54,3 +54,53 @@ describe('fetchRoutines', () => { await expect(fetchRoutines()).rejects.toThrow(); }); }); + +describe('createRoutine', () => { + it('creates a routine with CSRF protection and validates the response', async () => { + const response = { + id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + name: 'Upper Body', + description: null, + exerciseCount: 1, + scheduledDays: [1], + createdAt: '2026-08-26T10:00:00.000Z', + updatedAt: '2026-08-26T10:00:00.000Z', + }; + const input = { + name: 'Upper Body', + exercises: [ + { + exerciseId: '16d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + position: 0, + sets: 3, + targetReps: 8, + weight: 10, + weightUnit: 'lb' as const, + restBetweenSetsSeconds: 60, + restAfterExerciseSeconds: 0, + notes: null, + }, + ], + schedules: [{ dayOfWeek: 1, localTime: '18:00:00' }], + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ csrfToken: 'csrf-token-123' })) + .mockResolvedValueOnce(Response.json(response, { status: 201 })); + vi.stubGlobal('fetch', fetchMock); + + await expect(createRoutine(input)).resolves.toEqual(response); + expect(fetchMock).toHaveBeenNthCalledWith(1, '/auth/csrf-token', { + credentials: 'include', + }); + + const requestInit = fetchMock.mock.calls[1]?.[1] as RequestInit; + expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/routines'); + expect(requestInit.credentials).toBe('include'); + expect(requestInit.method).toBe('POST'); + expect(new Headers(requestInit.headers).get('X-CSRF-Token')).toBe( + 'csrf-token-123', + ); + expect(JSON.parse(requestInit.body as string)).toEqual(input); + }); +}); diff --git a/apps/web/src/features/routines/api/routines.api.ts b/apps/web/src/features/routines/api/routines.api.ts index 5c56dde..f68aeed 100644 --- a/apps/web/src/features/routines/api/routines.api.ts +++ b/apps/web/src/features/routines/api/routines.api.ts @@ -1,9 +1,15 @@ /** Fetches and validates browser-safe routine data from the backend API. */ import { + createRoutineInputSchema, routineListResponseSchema, + routineSummarySchema, + type CreateRoutineInput, type RoutineListResponse, + type RoutineSummary, } from '@goforlift/contracts'; +import { requestWithCsrf } from '@/features/auth/auth.api'; + export class RoutinesApiError extends Error { constructor(public readonly status: number) { super(`Routines API request failed with status ${status}`); @@ -26,3 +32,17 @@ export async function fetchRoutines( const data: unknown = await response.json(); return routineListResponseSchema.parse(data); } + +export async function createRoutine( + input: CreateRoutineInput, +): Promise { + const validatedInput = createRoutineInputSchema.parse(input); + const response = await requestWithCsrf('/api/routines', { + body: JSON.stringify(validatedInput), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const data: unknown = await response.json(); + + return routineSummarySchema.parse(data); +} diff --git a/apps/web/src/features/routines/api/routines.mutation.ts b/apps/web/src/features/routines/api/routines.mutation.ts new file mode 100644 index 0000000..96a8997 --- /dev/null +++ b/apps/web/src/features/routines/api/routines.mutation.ts @@ -0,0 +1,15 @@ +/** Creates routines and marks the authenticated routine collection as stale. */ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { createRoutine } from './routines.api'; +import { routinesQueryKey } from './routines.query'; + +export function useCreateRoutineMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createRoutine, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: routinesQueryKey }), + }); +} diff --git a/apps/web/src/features/routines/create/editor/routine-editor.test.tsx b/apps/web/src/features/routines/create/editor/routine-editor.test.tsx index 4eca5e3..d8df598 100644 --- a/apps/web/src/features/routines/create/editor/routine-editor.test.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.test.tsx @@ -25,6 +25,7 @@ afterEach(() => { describe('routine editor', () => { it('shows the initial editor shell without enabling unfinished actions', async () => { + const user = userEvent.setup(); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); renderEditor(); @@ -39,7 +40,9 @@ describe('routine editor', () => { expect( screen.getByRole('heading', { name: 'Exercises (0)' }), ).toBeVisible(); - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + await user.type(screen.getByLabelText('Routine Name'), 'Push Day'); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); expect( screen.queryByRole('button', { name: 'Preview' }), ).not.toBeInTheDocument(); @@ -51,6 +54,32 @@ describe('routine editor', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('shows inline errors when saving an incomplete routine', async () => { + const user = userEvent.setup(); + renderEditor(); + + await user.click(await screen.findByRole('button', { name: 'Save' })); + + expect(screen.getByText('Enter a routine name.')).toBeVisible(); + expect(screen.getByText('Add at least one exercise.')).toBeVisible(); + expect(screen.getByText('Complete required fields.')).toBeVisible(); + + await user.type(screen.getByLabelText('Routine Name'), 'Push Day'); + expect(screen.getByText('Complete required fields.')).toHaveClass( + 'invisible', + ); + + await user.clear(screen.getByLabelText('Routine Name')); + expect(screen.getByText('Complete required fields.')).toHaveClass( + 'invisible', + ); + + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByText('Complete required fields.')).not.toHaveClass( + 'invisible', + ); + }); + it('shows live validation and clears the error after correction', async () => { const user = userEvent.setup(); renderEditor(); diff --git a/apps/web/src/features/routines/create/editor/routine-editor.tsx b/apps/web/src/features/routines/create/editor/routine-editor.tsx index 89b4a5e..e624447 100644 --- a/apps/web/src/features/routines/create/editor/routine-editor.tsx +++ b/apps/web/src/features/routines/create/editor/routine-editor.tsx @@ -1,6 +1,7 @@ /** Presents the responsive visual shell for creating a registered-user routine. */ import { Link, useNavigate } from '@tanstack/react-router'; import { ArrowLeft, Plus } from 'lucide-react'; +import { useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { AddActionButton } from '@/components/ui/add-action-button'; @@ -8,17 +9,30 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { PageTitle } from '@/components/ui/page-title'; +import { useCreateRoutineMutation } from '@/features/routines/api/routines.mutation'; import { cn } from '@/lib/utils'; import type { + ParsedRoutineDraftFormValues, RoutineDraftExerciseFormValues, RoutineDraftFormValues, } from '../routine-draft-form'; +import { toCreateRoutineInput } from '../routine-draft.mapper'; +import { reorderRoutineExercises } from './routine-exercise-order'; import { RoutineExerciseList } from './routine-exercise-list'; import { RoutineScheduleField } from './routine-schedule-field'; export function RoutineEditor() { const navigate = useNavigate(); - const { reset } = useFormContext(); + + const [showRequiredFieldsMessage, setShowRequiredFieldsMessage] = + useState(false); + + const createRoutineMutation = useCreateRoutineMutation(); + const { handleSubmit, reset } = useFormContext< + RoutineDraftFormValues, + unknown, + ParsedRoutineDraftFormValues + >(); const addExercise = async () => { await navigate({ to: '/routines/new/exercises' }); @@ -32,11 +46,44 @@ export function RoutineEditor() { }); }; + function saveRoutine(draft: ParsedRoutineDraftFormValues) { + setShowRequiredFieldsMessage(false); + createRoutineMutation.mutate(toCreateRoutineInput(draft), { + onSuccess: () => { + reset(); + void navigate({ to: '/routines' }); + }, + }); + } + return (
- reset()} /> + { + reset(); + setShowRequiredFieldsMessage(false); + }} + showRequiredFieldsMessage={showRequiredFieldsMessage} + /> -
+ {createRoutineMutation.isError && ( +

+ Routine could not be saved. Please try again. +

+ )} + + setShowRequiredFieldsMessage(false)} + onClick={() => setShowRequiredFieldsMessage(false)} + onSubmit={(event) => + void handleSubmit(saveRoutine, () => + setShowRequiredFieldsMessage(true), + )(event) + } + > @@ -51,7 +98,15 @@ export function RoutineEditor() { ); } -function EditorHeader({ onClear }: { onClear: () => void }) { +function EditorHeader({ + isSaving, + onClear, + showRequiredFieldsMessage, +}: { + isSaving: boolean; + onClear: () => void; + showRequiredFieldsMessage: boolean; +}) { return (
Create Routine
- - +
+ +

+ Complete required fields. +

+
); @@ -114,9 +192,14 @@ function ExerciseSection({ onAddExercise: () => void; onEditExercise: (exercise: RoutineDraftExerciseFormValues) => void; }) { - const { control, getValues, setValue } = - useFormContext(); + const { + control, + formState: { errors, submitCount }, + getValues, + setValue, + } = useFormContext(); const exercises = useWatch({ control, name: 'exercises' }); + const errorMessage = errors.exercises?.message; function removeExercise(position: number) { const remainingExercises = getValues('exercises') @@ -132,17 +215,35 @@ function ExerciseSection({ }); } + function reorderExercises(fromIndex: number, toIndex: number) { + setValue( + 'exercises', + reorderRoutineExercises(exercises, fromIndex, toIndex), + { + shouldDirty: true, + shouldValidate: true, + }, + ); + } + return (
+ {submitCount > 0 && errorMessage && ( +

+ {errorMessage} +

+ )} + {exercises.length > 0 && ( )} diff --git a/apps/web/src/features/routines/create/editor/routine-exercise-list.tsx b/apps/web/src/features/routines/create/editor/routine-exercise-list.tsx index dd5ac0a..7b7963e 100644 --- a/apps/web/src/features/routines/create/editor/routine-exercise-list.tsx +++ b/apps/web/src/features/routines/create/editor/routine-exercise-list.tsx @@ -1,4 +1,6 @@ /** Displays configured exercises in the routine draft. */ +import { DragDropProvider } from '@dnd-kit/react'; +import { isSortable, useSortable } from '@dnd-kit/react/sortable'; import { Dumbbell, GripVertical, MoreHorizontal } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -15,10 +17,12 @@ export function RoutineExerciseList({ exercises, onEdit, onRemove, + onReorder, }: { exercises: RoutineDraftExerciseFormValues[]; onEdit: (exercise: RoutineDraftExerciseFormValues) => void; onRemove: (position: number) => void; + onReorder: (fromIndex: number, toIndex: number) => void; }) { return (
@@ -37,41 +41,69 @@ export function RoutineExerciseList({
-
    - {exercises.map((exercise) => ( - - ))} -
+ { + if (event.canceled) return; + + const { source } = event.operation; + + if (isSortable(source) && source.initialIndex !== source.index) { + onReorder(source.initialIndex, source.index); + } + }} + > +
    + {exercises.map((exercise, index) => ( + + ))} +
+
); } function RoutineExerciseRow({ exercise, + index, onEdit, onRemove, }: { exercise: RoutineDraftExerciseFormValues; + index: number; onEdit: (exercise: RoutineDraftExerciseFormValues) => void; onRemove: (position: number) => void; }) { + const { handleRef, isDragging, ref } = useSortable({ + id: exercise.draftExerciseId, + index, + }); + return (
  • -