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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,13 @@ defining the appropriate Zod schema in:

and infer TypeScript types from that schema.

Form validation that represents an API or domain constraint must reuse the
field schemas from `packages/contracts`. Do not recreate constraints such as
required values, trimming, minimums, maximums, or allowed values inside a
frontend feature. Frontend-only presentation behavior may wrap shared field
schemas in a form-specific object schema, but the underlying validation rules
must remain shared.

Example:

export const CreateRoutineSchema = z.object({
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/features/routines/routine-draft-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** Provides one routine draft form across every route in the creation flow. */
import { zodResolver } from '@hookform/resolvers/zod';
import { routineNameSchema } from '@goforlift/contracts';
import type { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { z } from 'zod';

const routineDraftFormSchema = z.object({
name: routineNameSchema,
});

export type RoutineDraftFormValues = z.infer<typeof routineDraftFormSchema>;

export function RoutineDraftFormProvider({
children,
}: {
children: ReactNode;
}) {
const form = useForm<RoutineDraftFormValues>({
defaultValues: { name: '' },
mode: 'onChange', // when to run validation.
resolver: zodResolver(routineDraftFormSchema),
});

return <FormProvider {...form}>{children}</FormProvider>;
}
92 changes: 83 additions & 9 deletions apps/web/src/features/routines/routine-editor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
/** Verifies the routine editor shell exposes its initial responsive workflow. */
import { ROUTINE_NAME_MAX_LENGTH } from '@goforlift/contracts';
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
Link,
Outlet,
RouterProvider,
} from '@tanstack/react-router';
import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it } from 'vitest';

import { RoutineDraftFormProvider } from './routine-draft-form';
import { RoutineEditor } from './routine-editor';

afterEach(cleanup);
afterEach(() => {
cleanup();
});

describe('routine editor', () => {
it('shows the initial editor shell without enabling unfinished actions', async () => {
Expand All @@ -28,28 +35,95 @@ describe('routine editor', () => {
screen.getByRole('heading', { name: 'Exercises (0)' }),
).toBeVisible();
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(screen.getByRole('link', { name: 'Add Exercise' })).toHaveAttribute(
'href',
'/routines/new/exercises',
);
expect(screen.getByRole('button', { name: 'Add Exercise' })).toBeVisible();
expect(screen.getByRole('button', { name: 'Add schedule' })).toBeDisabled();
expect(
screen.getByRole('link', { name: 'Back to routines' }),
).toHaveAttribute('href', '/routines');
});

it('shows live validation and clears the error after correction', async () => {
const user = userEvent.setup();
renderEditor();

const routineNameInput = await screen.findByLabelText('Routine Name');

await user.type(routineNameInput, 'x');
await user.clear(routineNameInput);

expect(await screen.findByRole('alert')).toHaveTextContent(
'Enter a routine name.',
);
expect(routineNameInput).toHaveAttribute('aria-invalid', 'true');

await user.type(routineNameInput, 'x'.repeat(ROUTINE_NAME_MAX_LENGTH + 1));

expect(await screen.findByRole('alert')).toBeVisible();
expect(routineNameInput).toHaveAttribute('aria-invalid', 'true');

await user.clear(routineNameInput);
await user.type(routineNameInput, 'Push');

await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
expect(routineNameInput).not.toHaveAttribute('aria-invalid');
});

it('keeps the current draft and opens exercises when the name is invalid', async () => {
const user = userEvent.setup();
const router = renderEditor();
const invalidName = 'x'.repeat(ROUTINE_NAME_MAX_LENGTH + 1);
const routineNameInput = await screen.findByLabelText('Routine Name');

await user.type(routineNameInput, invalidName);

expect(await screen.findByRole('alert')).toBeVisible();

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

await waitFor(() =>
expect(router.state.location.pathname).toBe('/routines/new/exercises'),
);

await user.click(screen.getByRole('link', { name: 'Back to editor' }));

expect(await screen.findByRole('alert')).toBeVisible();
expect(screen.getByLabelText('Routine Name')).toHaveValue(invalidName);
});
});

function renderEditor() {
const rootRoute = createRootRoute();
const editorRoute = createRoute({
const routineCreationRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'routine-creation',
component: TestRoutineCreationLayout,
});
const editorRoute = createRoute({
getParentRoute: () => routineCreationRoute,
path: '/routines/new',
component: RoutineEditor,
});
const exercisePickerRoute = createRoute({
getParentRoute: () => routineCreationRoute,
path: '/routines/new/exercises',
component: () => <Link to="/routines/new">Back to editor</Link>,
});
const testRouter = createRouter({
history: createMemoryHistory({ initialEntries: ['/routines/new'] }),
routeTree: rootRoute.addChildren([editorRoute]),
routeTree: rootRoute.addChildren([
routineCreationRoute.addChildren([editorRoute, exercisePickerRoute]),
]),
});

return render(<RouterProvider router={testRouter} />);
render(<RouterProvider router={testRouter} />);

return testRouter;
}

function TestRoutineCreationLayout() {
return (
<RoutineDraftFormProvider>
<Outlet />
</RoutineDraftFormProvider>
);
}
54 changes: 42 additions & 12 deletions apps/web/src/features/routines/routine-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
/** Presents the responsive visual shell for creating a registered-user routine. */
import { Link } from '@tanstack/react-router';
import { Link, useNavigate } from '@tanstack/react-router';
import { ArrowLeft, Plus } from 'lucide-react';
import { useFormContext } 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 { cn } from '../../lib/utils';
import type { RoutineDraftFormValues } from './routine-draft-form';
import { RoutineScheduleField } from './routine-schedule-field';

export function RoutineEditor() {
const navigate = useNavigate();

const addExercise = async () => {
await navigate({ to: '/routines/new/exercises' });
};

return (
<section className="mx-auto w-full max-w-5xl">
<EditorHeader />

<div className="grid gap-6 lg:grid-cols-2">
<form className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
<RoutineDetails />
<ExerciseSection />
<ExerciseSection onAddExercise={() => void addExercise()} />
</div>

<RoutineScheduleField />
</div>
</form>
</section>
);
}
Expand All @@ -46,31 +55,52 @@ function EditorHeader() {
}

function RoutineDetails() {
const {
formState: { errors },
register,
} = useFormContext<RoutineDraftFormValues>();

const errorMessage = errors.name?.message;

return (
<div>
<Label htmlFor="routine-name">Routine Name</Label>
<Input
className="mt-2"
aria-describedby={errorMessage ? 'routine-name-error' : undefined}
aria-invalid={errorMessage ? true : undefined}
className={cn(
'mt-2',
'focus-visible:border-foreground/40 focus-visible:ring-foreground/10',
'aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/30',
'aria-invalid:focus-visible:border-destructive aria-invalid:focus-visible:ring-destructive/30',
)}
id="routine-name"
maxLength={100}
placeholder="e.g., Upper Body"
{...register('name')}
/>
{errorMessage && (
<p
className="mt-2 text-sm text-destructive"
id="routine-name-error"
role="alert"
>
{errorMessage}
</p>
)}
</div>
);
}

function ExerciseSection() {
function ExerciseSection({ onAddExercise }: { onAddExercise: () => void }) {
return (
<div>
<Label asChild>
<h2>Exercises (0)</h2>
</Label>
<div className="mt-3">
<AddActionButton asChild>
<Link to="/routines/new/exercises">
<Plus aria-hidden="true" />
Add Exercise
</Link>
<AddActionButton onClick={onAddExercise} type="button">
<Plus aria-hidden="true" />
Add Exercise
</AddActionButton>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/features/routines/routines-landing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe('routines landing', () => {
expect(
await screen.findByRole('link', { name: 'Add Routine' }),
).toBeVisible();

expect(screen.queryByText('No routines yet.')).not.toBeInTheDocument();
});

Expand Down
Loading