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: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 51 additions & 1 deletion apps/web/src/features/routines/api/routines.api.test.ts
Original file line number Diff line number Diff line change
@@ -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());

Expand Down Expand Up @@ -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);
});
});
20 changes: 20 additions & 0 deletions apps/web/src/features/routines/api/routines.api.ts
Original file line number Diff line number Diff line change
@@ -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}`);
Expand All @@ -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<RoutineSummary> {
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);
}
15 changes: 15 additions & 0 deletions apps/web/src/features/routines/api/routines.mutation.ts
Original file line number Diff line number Diff line change
@@ -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 }),
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down
117 changes: 109 additions & 8 deletions apps/web/src/features/routines/create/editor/routine-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
/** 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';
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<RoutineDraftFormValues>();

const [showRequiredFieldsMessage, setShowRequiredFieldsMessage] =
useState(false);

const createRoutineMutation = useCreateRoutineMutation();
const { handleSubmit, reset } = useFormContext<
RoutineDraftFormValues,
unknown,
ParsedRoutineDraftFormValues
>();

const addExercise = async () => {
await navigate({ to: '/routines/new/exercises' });
Expand All @@ -32,11 +46,44 @@ export function RoutineEditor() {
});
};

function saveRoutine(draft: ParsedRoutineDraftFormValues) {
setShowRequiredFieldsMessage(false);
createRoutineMutation.mutate(toCreateRoutineInput(draft), {
onSuccess: () => {
reset();
void navigate({ to: '/routines' });
},
});
}

return (
<section className="mx-auto w-full max-w-5xl">
<EditorHeader onClear={() => reset()} />
<EditorHeader
isSaving={createRoutineMutation.isPending}
onClear={() => {
reset();
setShowRequiredFieldsMessage(false);
}}
showRequiredFieldsMessage={showRequiredFieldsMessage}
/>

<form className="grid gap-6 lg:grid-cols-2">
{createRoutineMutation.isError && (
<p className="mb-5 text-sm text-destructive" role="alert">
Routine could not be saved. Please try again.
</p>
)}

<form
className="grid gap-6 lg:grid-cols-2"
id="routine-editor-form"
onChange={() => setShowRequiredFieldsMessage(false)}
onClick={() => setShowRequiredFieldsMessage(false)}
onSubmit={(event) =>
void handleSubmit(saveRoutine, () =>
setShowRequiredFieldsMessage(true),
)(event)
}
>
<RoutineDetails />
<RoutineScheduleField />

Expand All @@ -51,7 +98,15 @@ export function RoutineEditor() {
);
}

function EditorHeader({ onClear }: { onClear: () => void }) {
function EditorHeader({
isSaving,
onClear,
showRequiredFieldsMessage,
}: {
isSaving: boolean;
onClear: () => void;
showRequiredFieldsMessage: boolean;
}) {
return (
<header className="mb-7 flex items-center gap-3 border-b pb-5">
<Button aria-label="Back to routines" asChild size="icon" variant="ghost">
Expand All @@ -61,10 +116,33 @@ function EditorHeader({ onClear }: { onClear: () => void }) {
</Button>
<PageTitle>Create Routine</PageTitle>
<div className="ml-auto flex gap-2">
<Button onClick={onClear} type="button" variant="ghost">
<Button
disabled={isSaving}
onClick={onClear}
type="button"
variant="ghost"
>
Clear
</Button>
<Button disabled>Save</Button>
<div className="flex w-20 flex-col items-start gap-1">
<Button
className="w-full"
disabled={isSaving}
form="routine-editor-form"
type="submit"
>
{isSaving ? 'Saving…' : 'Save'}
</Button>
<p
aria-live="polite"
className={cn(
'min-h-12 w-full text-left text-xs text-destructive',
!showRequiredFieldsMessage && 'invisible',
)}
>
Complete required fields.
</p>
</div>
</div>
</header>
);
Expand Down Expand Up @@ -114,9 +192,14 @@ function ExerciseSection({
onAddExercise: () => void;
onEditExercise: (exercise: RoutineDraftExerciseFormValues) => void;
}) {
const { control, getValues, setValue } =
useFormContext<RoutineDraftFormValues>();
const {
control,
formState: { errors, submitCount },
getValues,
setValue,
} = useFormContext<RoutineDraftFormValues>();
const exercises = useWatch({ control, name: 'exercises' });
const errorMessage = errors.exercises?.message;

function removeExercise(position: number) {
const remainingExercises = getValues('exercises')
Expand All @@ -132,17 +215,35 @@ function ExerciseSection({
});
}

function reorderExercises(fromIndex: number, toIndex: number) {
setValue(
'exercises',
reorderRoutineExercises(exercises, fromIndex, toIndex),
{
shouldDirty: true,
shouldValidate: true,
},
);
}

return (
<div>
<Label asChild>
<h2>Exercises ({exercises.length})</h2>
</Label>

{submitCount > 0 && errorMessage && (
<p className="mt-2 text-sm text-destructive" role="alert">
{errorMessage}
</p>
)}

{exercises.length > 0 && (
<RoutineExerciseList
exercises={exercises}
onEdit={onEditExercise}
onRemove={removeExercise}
onReorder={reorderExercises}
/>
)}

Expand Down
Loading