Skip to content
75 changes: 75 additions & 0 deletions apps/web/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { X } from 'lucide-react';
import type { ComponentProps } from 'react';
import { Dialog as DialogPrimitive } from 'radix-ui';

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

const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;

function DialogContent({
children,
className,
...props
}: ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/70 backdrop-blur-xs" />
<DialogPrimitive.Content
className={cn(
'fixed right-0 bottom-0 left-0 z-50 max-h-[90vh] overflow-y-auto rounded-t-2xl border bg-background p-5 shadow-2xl',
'focus:outline-none',
'sm:top-1/2 sm:right-auto sm:bottom-auto sm:left-1/2 sm:w-[min(34rem,calc(100vw-2rem))] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:p-6',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
aria-label="Close"
className={cn(
'absolute top-4 right-4 inline-flex size-9 items-center justify-center rounded-md',
'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<X aria-hidden="true" className="size-5" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}

function DialogTitle({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn('text-lg font-semibold sm:text-xl', className)}
{...props}
/>
);
}

function DialogDescription({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}

export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
};
29 changes: 29 additions & 0 deletions apps/web/src/components/ui/switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ComponentProps } from 'react';
import { Switch as SwitchPrimitive } from 'radix-ui';

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

export function Switch({
className,
...props
}: ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
className={cn(
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent p-0.5',
'bg-input transition-colors data-[state=checked]:bg-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
'block size-4 rounded-full bg-foreground shadow-sm transition-transform',
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { RoutineDraftFormProvider } from './routine-draft-form';
import { exercisesQueryKey } from '@/features/exercises/exercises.query';
import { RoutineDraftFormProvider } from '../routine-draft-form';
import { RoutineEditor } from './routine-editor';
import { exercisesQueryKey } from '../exercises/exercises.query';

afterEach(() => {
cleanup();
Expand All @@ -40,8 +40,11 @@ describe('routine editor', () => {
screen.getByRole('heading', { name: 'Exercises (0)' }),
).toBeVisible();
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(
screen.queryByRole('button', { name: 'Preview' }),
).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add Exercise' })).toBeVisible();
expect(screen.getByRole('button', { name: 'Add schedule' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Add schedule' })).toBeEnabled();
expect(
screen.getByRole('link', { name: 'Back to routines' }),
).toHaveAttribute('href', '/routines');
Expand Down Expand Up @@ -74,6 +77,27 @@ describe('routine editor', () => {
expect(routineNameInput).not.toHaveAttribute('aria-invalid');
});

it('clears the draft and its validation state when requested', async () => {
const user = userEvent.setup();
renderEditor();
const routineNameInput = await screen.findByLabelText('Routine Name');

await user.type(routineNameInput, 'x'.repeat(ROUTINE_NAME_MAX_LENGTH + 1));
expect(await screen.findByRole('alert')).toBeVisible();

await user.click(screen.getByRole('button', { name: 'Add schedule' }));
await user.click(screen.getByRole('switch', { name: 'Schedule Monday' }));
await user.click(screen.getByRole('button', { name: 'Save Schedule' }));
expect(screen.getByText('Mon')).toBeVisible();

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

expect(routineNameInput).toHaveValue('');
expect(routineNameInput).not.toHaveAttribute('aria-invalid');
expect(screen.queryByRole('alert')).toBeNull();
expect(screen.queryByText('Mon')).toBeNull();
});

it('keeps the current draft and opens exercises when the name is invalid', async () => {
const user = userEvent.setup();
const router = renderEditor();
Expand All @@ -95,33 +119,53 @@ describe('routine editor', () => {
expect(await screen.findByRole('alert')).toBeVisible();
expect(screen.getByLabelText('Routine Name')).toHaveValue(invalidName);
});

it('keeps the draft while visiting a different application page', async () => {
const user = userEvent.setup();
const router = renderEditor();
const invalidName = 'x'.repeat(ROUTINE_NAME_MAX_LENGTH + 1);

await user.type(await screen.findByLabelText('Routine Name'), invalidName);
expect(await screen.findByRole('alert')).toBeVisible();

await router.navigate({ to: '/history' });
expect(
await screen.findByRole('heading', { name: 'History' }),
).toBeVisible();

await router.navigate({ to: '/routines/new' });
expect(await screen.findByRole('alert')).toBeVisible();
expect(screen.getByLabelText('Routine Name')).toHaveValue(invalidName);
});
});

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

Expand All @@ -134,7 +178,7 @@ function renderEditor() {
return testRouter;
}

function TestRoutineCreationLayout() {
function TestApplicationLayout() {
return (
<RoutineDraftFormProvider>
<Outlet />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,27 @@ import { Link, useNavigate } from '@tanstack/react-router';
import { ArrowLeft, Plus } from 'lucide-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 { cn } from '../../lib/utils';
import type { RoutineDraftFormValues } from './routine-draft-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 { RoutineExerciseList } from './routine-exercise-list';
import { RoutineScheduleField } from './routine-schedule-field';

export function RoutineEditor() {
const navigate = useNavigate();
const { reset } = useFormContext<RoutineDraftFormValues>();

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

return (
<section className="mx-auto w-full max-w-5xl">
<EditorHeader />
<EditorHeader onClear={() => reset()} />

<form className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
Expand All @@ -36,7 +37,7 @@ export function RoutineEditor() {
);
}

function EditorHeader() {
function EditorHeader({ onClear }: { onClear: () => void }) {
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 @@ -46,8 +47,8 @@ function EditorHeader() {
</Button>
<PageTitle>Create Routine</PageTitle>
<div className="ml-auto flex gap-2">
<Button className="hidden sm:inline-flex" disabled variant="outline">
Preview
<Button onClick={onClear} type="button" variant="ghost">
Clear
</Button>
<Button disabled>Save</Button>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/** Displays configured exercises in the routine draft. */
import { Dumbbell, GripVertical, MoreHorizontal } from 'lucide-react';

import { cn } from '../../lib/utils';
import type { RoutineDraftExerciseFormValues } from './routine-draft-form';
import { cn } from '@/lib/utils';
import type { RoutineDraftExerciseFormValues } from '../routine-draft-form';

export function RoutineExerciseList({
exercises,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';

import {
INITIAL_SCHEDULE_EDITOR_STATE,
scheduleEditorReducer,
} from './routine-schedule-editor.reducer';

describe('schedule editor reducer', () => {
it('selects a day with the current shared time', () => {
const mondaySelected = scheduleEditorReducer(
INITIAL_SCHEDULE_EDITOR_STATE,
{ type: 'day-toggled', dayOfWeek: 1, isSelected: true },
);
const mondayTimeChanged = scheduleEditorReducer(mondaySelected, {
type: 'time-changed',
dayOfWeek: 1,
time: '07:30',
});
const wednesdaySelected = scheduleEditorReducer(mondayTimeChanged, {
type: 'day-toggled',
dayOfWeek: 3,
isSelected: true,
});

expect(wednesdaySelected.days[3]).toMatchObject({
isSelected: true,
time: '07:30',
});
});

it('updates selected days together while shared time is enabled', () => {
let state = scheduleEditorReducer(INITIAL_SCHEDULE_EDITOR_STATE, {
type: 'day-toggled',
dayOfWeek: 1,
isSelected: true,
});
state = scheduleEditorReducer(state, {
type: 'day-toggled',
dayOfWeek: 3,
isSelected: true,
});
state = scheduleEditorReducer(state, {
type: 'time-changed',
dayOfWeek: 1,
time: '08:15',
});

expect(state.days[1]?.time).toBe('08:15');
expect(state.days[3]?.time).toBe('08:15');
});

it('updates only one day after shared time is disabled', () => {
let state = scheduleEditorReducer(INITIAL_SCHEDULE_EDITOR_STATE, {
type: 'day-toggled',
dayOfWeek: 1,
isSelected: true,
});
state = scheduleEditorReducer(state, {
type: 'day-toggled',
dayOfWeek: 3,
isSelected: true,
});
state = scheduleEditorReducer(state, {
type: 'shared-time-toggled',
isEnabled: false,
});
state = scheduleEditorReducer(state, {
type: 'time-changed',
dayOfWeek: 1,
time: '09:00',
});

expect(state.days[1]?.time).toBe('09:00');
expect(state.days[3]?.time).toBe('18:00');
});
});
Loading