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
40 changes: 39 additions & 1 deletion apps/web/src/features/exercises/exercises.api.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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' }),
);
});
20 changes: 20 additions & 0 deletions apps/web/src/features/exercises/exercises.api.ts
Original file line number Diff line number Diff line change
@@ -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}`);
Expand All @@ -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<ExerciseSummary> {
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);
}
24 changes: 24 additions & 0 deletions apps/web/src/features/exercises/exercises.mutation.ts
Original file line number Diff line number Diff line change
@@ -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<ExerciseListResponse>(
exercisesQueryKey,
(currentData) => ({
exercises: currentData
? [...currentData.exercises, createdExercise]
: [createdExercise],
}),
);
},
});
}
Original file line number Diff line number Diff line change
@@ -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<CreateExerciseInput>({
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 (
<Dialog onOpenChange={handleOpenChange} open={isOpen}>
<DialogTrigger asChild>
<AddActionButton className="border border-dashed border-input lg:h-16 lg:text-base">
<Plus aria-hidden="true" />
Create Custom Exercise
</AddActionButton>
</DialogTrigger>

<DialogContent>
<DialogTitle>Create Custom Exercise</DialogTitle>
<DialogDescription className="mt-1">
Add an exercise that will only be available to you.
</DialogDescription>

<form
className="mt-6 space-y-5"
onSubmit={(event) => void handleSubmit(saveCustomExercise)(event)}
>
<div>
<Label htmlFor="custom-exercise-name">Exercise Name</Label>
<Input
aria-describedby={
errors.name ? 'custom-exercise-name-error' : undefined
}
aria-invalid={Boolean(errors.name)}
className={cn('mt-2', errors.name && 'border-destructive')}
id="custom-exercise-name"
placeholder="E.g., Cable Pullover"
{...register('name')}
/>
{errors.name && (
<p
className="mt-1.5 text-sm text-destructive"
id="custom-exercise-name-error"
>
{errors.name.message}
</p>
)}
</div>

<div>
<Label htmlFor="custom-exercise-description" isOptional>
Description
</Label>
<textarea
className={cn(
'mt-2 min-h-28 w-full resize-y rounded-md border px-4 py-3',
'border-input bg-surface-elevated text-base placeholder:text-muted-foreground',
'outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35',
)}
id="custom-exercise-description"
placeholder="Describe how to perform the exercise."
{...register('description', {
setValueAs: (value: string) => value || null,
})}
/>
</div>

{createExerciseMutation.isError && (
<p className="text-sm text-destructive" role="alert">
Custom exercise could not be created. Please try again.
</p>
)}

<div className="grid grid-cols-2 gap-3 pt-2">
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button disabled={createExerciseMutation.isPending} type="submit">
{createExerciseMutation.isPending ? 'Creating…' : 'Create'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
RouterProvider,
} from '@tanstack/react-router';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { PropsWithChildren } from 'react';
import { afterEach, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -76,3 +77,28 @@ it('filters available exercises by name', async () => {
expect(screen.queryByText('Bicep Curl')).not.toBeInTheDocument();
expect(screen.getByText('Shoulder Press')).toBeVisible();
});

it('opens the custom exercise form and validates its required name', async () => {
const user = userEvent.setup();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(Response.json({ exercises: [] })),
);

render(<ExercisePicker />, { wrapper: Wrapper });

await user.click(
await screen.findByRole('button', { name: 'Create Custom Exercise' }),
);
expect(
screen.getByRole('heading', { name: 'Create Custom Exercise' }),
).toBeVisible();

await user.click(screen.getByRole('button', { name: 'Create' }));

expect(screen.getByLabelText('Exercise Name')).toHaveAttribute(
'aria-invalid',
'true',
);
expect(screen.getByText('Enter an exercise name.')).toBeVisible();
});
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
/** Lets a routine author search the exercises available to add to a routine. */
import type { ExerciseSummary } from '@goforlift/contracts';
import { Link } from '@tanstack/react-router';
import { ChevronRight, Dumbbell, Plus, Search, X } from 'lucide-react';
import { ChevronRight, Dumbbell, Search, X } from 'lucide-react';
import { useState } from 'react';

import { AddActionButton } from '@/components/ui/add-action-button';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { LoadingState } from '@/components/ui/loading-state';
import { PageTitle } from '@/components/ui/page-title';
import { useExercises } from '@/features/exercises/exercises.query';

import { CustomExerciseDialog } from './custom-exercise-dialog';

export function ExercisePicker() {
const [searchTerm, setSearchTerm] = useState('');
const exercisesQuery = useExercises();
Expand Down Expand Up @@ -71,13 +72,7 @@ export function ExercisePicker() {
<div className="mt-4">{content}</div>

<div className="mt-7">
<AddActionButton
className="border border-dashed border-input lg:h-16 lg:text-base"
disabled
>
<Plus aria-hidden="true" />
Create Custom Exercise
</AddActionButton>
<CustomExerciseDialog />
</div>
</section>
);
Expand Down
9 changes: 8 additions & 1 deletion packages/contracts/src/exercises/exercise.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ export const EXERCISE_NAME_MAX_LENGTH = 100;

export const createExerciseInputSchema = z
.object({
name: z.string().trim().min(1).max(EXERCISE_NAME_MAX_LENGTH),
name: z
.string()
.trim()
.min(1, 'Enter an exercise name.')
.max(
EXERCISE_NAME_MAX_LENGTH,
`Maximum length is ${EXERCISE_NAME_MAX_LENGTH} characters`,
),
description: z.string().trim().nullable().optional(),
})
.strict();
Expand Down