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
30 changes: 30 additions & 0 deletions apps/web/src/components/ui/loading-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Displays a consistent centered spinner while page content is loading.
import { LoaderCircle } from 'lucide-react';
import type { ComponentProps } from 'react';

import { cn } from '../../lib/utils';

type LoadingStateProps = ComponentProps<'div'> & {
label?: string;
};

export function LoadingState({
className,
label = 'Loading…',
...props
}: LoadingStateProps) {
return (
<div
className={cn(
'flex min-h-[50vh] flex-col items-center justify-center gap-3',
'text-sm text-muted-foreground',
className,
)}
role="status"
{...props}
>
<LoaderCircle aria-hidden="true" className="size-6 animate-spin" />
<span>{label}</span>
</div>
);
}
46 changes: 46 additions & 0 deletions apps/web/src/features/exercises/exercises.api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/** Verifies exercise API status and shared-contract handling. */
import { afterEach, expect, it, vi } from 'vitest';

import { ExercisesApiError, fetchExercises } from './exercises.api';

afterEach(() => {
vi.unstubAllGlobals();
});

it('returns exercises that satisfy the shared contract', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
Response.json({
exercises: [
{
id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
name: 'Bicep Curl',
description: null,
isCustom: false,
},
],
}),
),
);

await expect(fetchExercises()).resolves.toEqual({
exercises: [
{
id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
name: 'Bicep Curl',
description: null,
isCustom: false,
},
],
});
});

it('throws a typed error when the request fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(null, { status: 500 })),
);

await expect(fetchExercises()).rejects.toEqual(new ExercisesApiError(500));
});
28 changes: 28 additions & 0 deletions apps/web/src/features/exercises/exercises.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** Fetches and validates exercises available to the authenticated user. */
import {
exerciseListResponseSchema,
type ExerciseListResponse,
} from '@goforlift/contracts';

export class ExercisesApiError extends Error {
constructor(public readonly status: number) {
super(`Exercises API request failed with status ${status}`);
this.name = 'ExercisesApiError';
}
}

export async function fetchExercises(
signal?: AbortSignal,
): Promise<ExerciseListResponse> {
const response = await fetch('/api/exercises', {
credentials: 'include',
signal,
});

if (!response.ok) {
throw new ExercisesApiError(response.status);
}

const data: unknown = await response.json();
return exerciseListResponseSchema.parse(data);
}
16 changes: 16 additions & 0 deletions apps/web/src/features/exercises/exercises.query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Provides the TanStack Query source for exercises available to the user. */
import { queryOptions, useQuery } from '@tanstack/react-query';

import { fetchExercises } from './exercises.api';

export const exercisesQueryKey = ['exercises'] as const;

export const exercisesQueryOptions = queryOptions({
queryKey: exercisesQueryKey,
queryFn: ({ signal }) => fetchExercises(signal),
retry: false,
});

export function useExercises() {
return useQuery(exercisesQueryOptions);
}
74 changes: 74 additions & 0 deletions apps/web/src/features/routines/exercise-picker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** Verifies exercise loading and client-side search in the routine picker. */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import type { PropsWithChildren } from 'react';
import { afterEach, expect, it, vi } from 'vitest';

import { ExercisePicker } from './exercise-picker';

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

function Wrapper({ children }: PropsWithChildren) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const rootRoute = createRootRoute({ component: () => children });
const router = createRouter({
history: createMemoryHistory({ initialEntries: ['/'] }),
routeTree: rootRoute,
});

return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}

it('filters available exercises by name', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
Response.json({
exercises: [
{
id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
name: 'Bicep Curl',
description: null,
isCustom: false,
},
{
id: '43805735-8ee9-4cbd-9da1-c7ec5965cb03',
name: 'Shoulder Press',
description: null,
isCustom: false,
},
],
}),
),
);

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

expect(await screen.findByText('Bicep Curl')).toBeVisible();
expect(screen.getByText('Shoulder Press')).toBeVisible();

fireEvent.change(
screen.getByRole('searchbox', { name: 'Search exercises' }),
{
target: { value: 'shoulder' },
},
);

expect(screen.queryByText('Bicep Curl')).not.toBeInTheDocument();
expect(screen.getByText('Shoulder Press')).toBeVisible();
});
108 changes: 108 additions & 0 deletions apps/web/src/features/routines/exercise-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/** 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 { 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 '../exercises/exercises.query';

export function ExercisePicker() {
const [searchTerm, setSearchTerm] = useState('');
const exercisesQuery = useExercises();

const normalizedSearchTerm = searchTerm.trim().toLocaleLowerCase();
const exercises = exercisesQuery.data?.exercises ?? [];
const visibleExercises = exercises.filter((exercise) =>
exercise.name.toLocaleLowerCase().includes(normalizedSearchTerm),
);

let content = <ExerciseList exercises={visibleExercises} />;

if (exercisesQuery.isPending) {
content = <LoadingState />;
} else if (exercisesQuery.isError) {
content = (
<p className="text-sm text-destructive">Exercises could not be loaded.</p>
);
} else if (visibleExercises.length === 0) {
content = (
<p className="text-sm text-muted-foreground">No exercises found.</p>
);
}

return (
<section className="mx-auto w-full max-w-2xl lg:max-w-3xl">
<header className="mb-5 flex items-center gap-3 lg:mb-7 lg:gap-4">
<Button
aria-label="Close exercise picker"
asChild
className="lg:size-10"
size="icon"
variant="ghost"
>
<Link to="/routines/new">
<X aria-hidden="true" />
</Link>
</Button>
<PageTitle className="flex-1 text-center">Add Exercise</PageTitle>
<div aria-hidden="true" className="size-9" />
</header>

<div className="relative">
<Input
aria-label="Search exercises"
className="h-12 rounded-lg bg-surface-elevated pr-11 pl-4 lg:h-14 lg:text-base"
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search exercises…"
type="search"
value={searchTerm}
/>
<Search
aria-hidden="true"
className="absolute top-1/2 right-4 size-5 -translate-y-1/2 text-muted-foreground lg:size-6"
/>
</div>

<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>
</div>
</section>
);
}

function ExerciseList({ exercises }: { exercises: ExerciseSummary[] }) {
return (
<ul>
{exercises.map((exercise) => (
<li
className="relative flex min-h-12 items-center gap-3 rounded-md px-2 py-1.5 transition-colors after:absolute after:bottom-0 after:left-[12.5%] after:h-0.5 after:w-3/4 after:bg-border/20 after:content-[''] last:after:hidden hover:bg-accent/50 lg:min-h-14 lg:gap-4 lg:px-3 lg:py-2"
key={exercise.id}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-surface-elevated text-primary lg:size-10">
<Dumbbell aria-hidden="true" className="size-5 lg:size-6" />
</div>
<p className="min-w-0 flex-1 truncate text-[15px] font-normal text-muted-foreground lg:text-base">
{exercise.name}
</p>
<ChevronRight
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground lg:size-5"
/>
</li>
))}
</ul>
);
}
5 changes: 4 additions & 1 deletion apps/web/src/features/routines/routine-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ describe('routine editor', () => {
screen.getByRole('heading', { name: 'Exercises (0)' }),
).toBeVisible();
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Add Exercise' })).toBeDisabled();
expect(screen.getByRole('link', { name: 'Add Exercise' })).toHaveAttribute(
'href',
'/routines/new/exercises',
);
expect(screen.getByRole('button', { name: 'Add schedule' })).toBeDisabled();
expect(
screen.getByRole('link', { name: 'Back to routines' }),
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/features/routines/routine-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ function ExerciseSection() {
<h2>Exercises (0)</h2>
</Label>
<div className="mt-3">
<AddActionButton disabled>
<Plus aria-hidden="true" />
Add Exercise
<AddActionButton asChild>
<Link to="/routines/new/exercises">
<Plus aria-hidden="true" />
Add Exercise
</Link>
</AddActionButton>
</div>
</div>
Expand Down
Loading