feat: add form input components with tests

- Add TagInput component for equipment tags
- Add ExerciseList component for workout exercises
- Both components include comprehensive test suites
- Add data-testid attributes for testability
This commit is contained in:
Millian Lamiaux
2026-03-14 20:42:36 +01:00
parent 71e9a9bdb5
commit 3df7dd4a47
4 changed files with 748 additions and 0 deletions

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ExerciseList } from './exercise-list'
describe('ExerciseList', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render with empty list showing add button', () => {
render(
<ExerciseList
value={[]}
onChange={mockOnChange}
/>
)
expect(screen.getByText('Add Exercise')).toBeInTheDocument()
})
it('should add new exercise on button click', async () => {
const user = userEvent.setup()
render(
<ExerciseList
value={[{ name: 'Push-ups', duration: 20 }]}
onChange={mockOnChange}
/>
)
const addButton = screen.getByText('Add Exercise')
await user.click(addButton)
expect(mockOnChange).toHaveBeenCalledWith([
{ name: 'Push-ups', duration: 20 },
{ name: '', duration: 20 },
])
})
it('should remove exercise on delete click', async () => {
const user = userEvent.setup()
render(
<ExerciseList
value={[
{ name: 'Push-ups', duration: 20 },
{ name: 'Squats', duration: 20 },
]}
onChange={mockOnChange}
/>
)
const deleteButton = screen.getByTestId('delete-exercise-0')
await user.click(deleteButton)
expect(mockOnChange).toHaveBeenCalledWith([
{ name: 'Squats', duration: 20 },
])
})
it('should update exercise name', () => {
render(
<ExerciseList
value={[{ name: 'Push-ups', duration: 20 }]}
onChange={mockOnChange}
/>
)
// Get the input and simulate change directly using fireEvent
const nameInput = screen.getByTestId('exercise-name-0')
fireEvent.change(nameInput, { target: { value: 'Burpees' } })
// Check that onChange was called with correct value
expect(mockOnChange).toHaveBeenCalledWith([{ name: 'Burpees', duration: 20 }])
})
it('should update exercise duration', () => {
render(
<ExerciseList
value={[{ name: 'Push-ups', duration: 20 }]}
onChange={mockOnChange}
/>
)
// Get the input and simulate change directly using fireEvent
const durationInput = screen.getByTestId('exercise-duration-0')
fireEvent.change(durationInput, { target: { value: '30' } })
// Check that onChange was called with correct value
expect(mockOnChange).toHaveBeenCalledWith([{ name: 'Push-ups', duration: 30 }])
})
it('should disable delete button when only one exercise', () => {
render(
<ExerciseList
value={[{ name: 'Push-ups', duration: 20 }]}
onChange={mockOnChange}
/>
)
const deleteButton = screen.getByTestId('delete-exercise-0')
expect(deleteButton).toBeDisabled()
})
it('should use workTimeDefault for new exercises', async () => {
const user = userEvent.setup()
render(
<ExerciseList
value={[]}
onChange={mockOnChange}
workTimeDefault={45}
/>
)
const addButton = screen.getByText('Add Exercise')
await user.click(addButton)
expect(mockOnChange).toHaveBeenCalledWith([
{ name: '', duration: 45 },
])
})
it('should move exercise up', async () => {
const user = userEvent.setup()
render(
<ExerciseList
value={[
{ name: 'First', duration: 20 },
{ name: 'Second', duration: 20 },
]}
onChange={mockOnChange}
/>
)
const upButton = screen.getByTestId('move-up-1')
expect(upButton).not.toBeDisabled()
await user.click(upButton)
expect(mockOnChange).toHaveBeenCalledWith([
{ name: 'Second', duration: 20 },
{ name: 'First', duration: 20 },
])
})
it('should move exercise down', async () => {
const user = userEvent.setup()
render(
<ExerciseList
value={[
{ name: 'First', duration: 20 },
{ name: 'Second', duration: 20 },
]}
onChange={mockOnChange}
/>
)
const downButton = screen.getByTestId('move-down-0')
expect(downButton).not.toBeDisabled()
await user.click(downButton)
expect(mockOnChange).toHaveBeenCalledWith([
{ name: 'Second', duration: 20 },
{ name: 'First', duration: 20 },
])
})
it('should disable move up on first item', () => {
render(
<ExerciseList
value={[
{ name: 'First', duration: 20 },
{ name: 'Second', duration: 20 },
]}
onChange={mockOnChange}
/>
)
const upButton = screen.getByTestId('move-up-0')
expect(upButton).toBeDisabled()
})
it('should disable move down on last item', () => {
render(
<ExerciseList
value={[
{ name: 'First', duration: 20 },
{ name: 'Second', duration: 20 },
]}
onChange={mockOnChange}
/>
)
const downButton = screen.getByTestId('move-down-1')
expect(downButton).toBeDisabled()
})
it('should respect disabled prop', () => {
render(
<ExerciseList
value={[{ name: 'Push-ups', duration: 20 }]}
onChange={mockOnChange}
disabled
/>
)
expect(screen.getByTestId('exercise-name-0')).toBeDisabled()
expect(screen.getByTestId('exercise-duration-0')).toBeDisabled()
expect(screen.getByText('Add Exercise')).toBeDisabled()
})
it('should render correct number of exercises', () => {
render(
<ExerciseList
value={[
{ name: 'First', duration: 20 },
{ name: 'Second', duration: 20 },
{ name: 'Third', duration: 20 },
]}
onChange={mockOnChange}
/>
)
// Check that we have 3 exercise name inputs
expect(screen.getByTestId('exercise-name-0')).toBeInTheDocument()
expect(screen.getByTestId('exercise-name-1')).toBeInTheDocument()
expect(screen.getByTestId('exercise-name-2')).toBeInTheDocument()
// Verify move buttons have correct aria-labels
expect(screen.getByTestId('move-up-0')).toHaveAttribute('aria-label', 'Move exercise 1 up')
expect(screen.getByTestId('move-up-1')).toHaveAttribute('aria-label', 'Move exercise 2 up')
expect(screen.getByTestId('move-up-2')).toHaveAttribute('aria-label', 'Move exercise 3 up')
})
})

View File

@@ -0,0 +1,161 @@
"use client"
import * as React from "react"
import { Plus, Trash2, GripVertical } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
interface Exercise {
name: string
duration: number
}
interface ExerciseListProps {
value: Exercise[]
onChange: (value: Exercise[]) => void
workTimeDefault?: number
className?: string
disabled?: boolean
}
function ExerciseList({
value,
onChange,
workTimeDefault = 20,
className,
disabled = false,
}: ExerciseListProps) {
const handleAdd = () => {
onChange([...value, { name: "", duration: workTimeDefault }])
}
const handleRemove = (index: number) => {
onChange(value.filter((_, i) => i !== index))
}
const handleUpdate = (
index: number,
field: keyof Exercise,
newValue: string | number
) => {
const updated = [...value]
updated[index] = { ...updated[index], [field]: newValue }
onChange(updated)
}
const handleMove = (index: number, direction: "up" | "down") => {
if (
(direction === "up" && index === 0) ||
(direction === "down" && index === value.length - 1)
) {
return
}
const newIndex = direction === "up" ? index - 1 : index + 1
const updated = [...value]
const [moved] = updated.splice(index, 1)
updated.splice(newIndex, 0, moved)
onChange(updated)
}
return (
<div className={cn("space-y-3", className)}>
<div className="space-y-2">
{value.map((exercise, index) => (
<div
key={index}
className="flex items-center gap-2 rounded-md border border-neutral-800 bg-neutral-900/50 p-3"
>
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => handleMove(index, "up")}
disabled={disabled || index === 0}
className="text-neutral-500 hover:text-white disabled:opacity-30"
aria-label={`Move exercise ${index + 1} up`}
data-testid={`move-up-${index}`}
>
<span className="block -rotate-90"></span>
</button>
<button
type="button"
onClick={() => handleMove(index, "down")}
disabled={disabled || index === value.length - 1}
className="text-neutral-500 hover:text-white disabled:opacity-30"
aria-label={`Move exercise ${index + 1} down`}
data-testid={`move-down-${index}`}
>
<span className="block rotate-90"></span>
</button>
</div>
<div className="flex flex-1 gap-3">
<div className="flex-1">
<label className="mb-1 block text-xs text-neutral-500">
Exercise Name
</label>
<Input
value={exercise.name}
onChange={(e) => handleUpdate(index, "name", e.target.value)}
placeholder="e.g., Jumping Jacks"
disabled={disabled}
className="h-9"
data-testid={`exercise-name-${index}`}
/>
</div>
<div className="w-24">
<label className="mb-1 block text-xs text-neutral-500">
Duration
</label>
<div className="flex items-center gap-1">
<Input
type="number"
value={exercise.duration}
onChange={(e) =>
handleUpdate(index, "duration", parseInt(e.target.value) || 0)
}
disabled={disabled}
className="h-9"
min={1}
data-testid={`exercise-duration-${index}`}
/>
<span className="text-sm text-neutral-500">s</span>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemove(index)}
disabled={disabled || value.length <= 1}
className="text-neutral-500 hover:text-red-500"
aria-label={`Delete exercise ${index + 1}`}
data-testid={`delete-exercise-${index}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
onClick={handleAdd}
disabled={disabled}
className="w-full border-dashed border-neutral-700 text-neutral-400 hover:border-orange-500 hover:text-orange-500"
>
<Plus className="mr-2 h-4 w-4" />
Add Exercise
</Button>
</div>
)
}
export { ExerciseList }
export type { Exercise }

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TagInput } from './tag-input'
describe('TagInput', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render empty input with placeholder', () => {
render(
<TagInput
value={[]}
onChange={mockOnChange}
placeholder="Add equipment..."
/>
)
expect(screen.getByPlaceholderText('Add equipment...')).toBeInTheDocument()
})
it('should add tag on Enter key', async () => {
const user = userEvent.setup()
render(
<TagInput
value={[]}
onChange={mockOnChange}
/>
)
const input = screen.getByRole('textbox')
await user.type(input, 'Dumbbells')
await user.keyboard('{Enter}')
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
})
it('should add tag on blur', async () => {
const user = userEvent.setup()
render(
<TagInput
value={[]}
onChange={mockOnChange}
/>
)
const input = screen.getByRole('textbox')
await user.type(input, 'Yoga Mat')
await user.tab() // blur
expect(mockOnChange).toHaveBeenCalledWith(['Yoga Mat'])
})
it('should not add empty tag', async () => {
const user = userEvent.setup()
render(
<TagInput
value={[]}
onChange={mockOnChange}
/>
)
const input = screen.getByRole('textbox')
await user.type(input, ' ')
await user.keyboard('{Enter}')
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should not add duplicate tags', async () => {
const user = userEvent.setup()
render(
<TagInput
value={['Dumbbells']}
onChange={mockOnChange}
/>
)
const input = screen.getByRole('textbox')
await user.type(input, 'Dumbbells')
await user.keyboard('{Enter}')
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should remove tag on X click', async () => {
const user = userEvent.setup()
render(
<TagInput
value={['Dumbbells', 'Yoga Mat']}
onChange={mockOnChange}
/>
)
// Find and click the remove button on first tag
const removeButtons = screen.getAllByRole('button', { name: '' })
await user.click(removeButtons[0])
expect(mockOnChange).toHaveBeenCalledWith(['Yoga Mat'])
})
it('should remove last tag on Backspace when input empty', async () => {
const user = userEvent.setup()
render(
<TagInput
value={['Dumbbells', 'Yoga Mat']}
onChange={mockOnChange}
/>
)
const input = screen.getByRole('textbox')
await user.click(input)
await user.keyboard('{Backspace}')
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
})
it('should not remove tag on Backspace when input has text', async () => {
const user = userEvent.setup()
render(
<TagInput
value={['Dumbbells']}
onChange={mockOnChange}
/>
)
const input = screen.getByRole('textbox')
await user.type(input, 'Yoga')
await user.keyboard('{Backspace}')
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should display all tags', () => {
render(
<TagInput
value={['Dumbbells', 'Yoga Mat', 'Resistance Band']}
onChange={mockOnChange}
/>
)
expect(screen.getByText('Dumbbells')).toBeInTheDocument()
expect(screen.getByText('Yoga Mat')).toBeInTheDocument()
expect(screen.getByText('Resistance Band')).toBeInTheDocument()
})
it('should trim whitespace from tags', async () => {
const user = userEvent.setup()
render(
<TagInput
value={[]}
onChange={mockOnChange}
/>
)
const input = screen.getByRole('textbox')
await user.type(input, ' Dumbbells ')
await user.keyboard('{Enter}')
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
})
it('should respect disabled prop', () => {
render(
<TagInput
value={['Dumbbells']}
onChange={mockOnChange}
disabled
/>
)
expect(screen.getByRole('textbox')).toBeDisabled()
// Remove button on tag should not be present when disabled
const removeButtons = screen.queryAllByRole('button', { name: '' })
expect(removeButtons).toHaveLength(0)
})
it('should clear input after adding tag', async () => {
const user = userEvent.setup()
render(
<TagInput
value={[]}
onChange={mockOnChange}
/>
)
const input = screen.getByRole('textbox') as HTMLInputElement
await user.type(input, 'Dumbbells')
await user.keyboard('{Enter}')
expect(input.value).toBe('')
})
it('should handle multiple tags addition', async () => {
const user = userEvent.setup()
const { rerender } = render(
<TagInput
value={[]}
onChange={mockOnChange}
/>
)
const input = screen.getByRole('textbox')
// Add first tag
await user.type(input, 'Dumbbells')
await user.keyboard('{Enter}')
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
// Rerender with updated value
rerender(
<TagInput
value={['Dumbbells']}
onChange={mockOnChange}
/>
)
// Add second tag
await user.type(input, 'Yoga Mat')
await user.keyboard('{Enter}')
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells', 'Yoga Mat'])
// Rerender with updated value
rerender(
<TagInput
value={['Dumbbells', 'Yoga Mat']}
onChange={mockOnChange}
/>
)
// Add third tag
await user.type(input, 'Resistance Band')
await user.keyboard('{Enter}')
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells', 'Yoga Mat', 'Resistance Band'])
})
})

View File

@@ -0,0 +1,100 @@
"use client"
import * as React from "react"
import { X, Plus } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
interface TagInputProps {
value: string[]
onChange: (value: string[]) => void
placeholder?: string
className?: string
disabled?: boolean
id?: string
}
function TagInput({
value,
onChange,
placeholder = "Add tag...",
className,
disabled = false,
}: TagInputProps) {
const [inputValue, setInputValue] = React.useState("")
const inputRef = React.useRef<HTMLInputElement>(null)
const handleAddTag = () => {
const trimmed = inputValue.trim()
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed])
setInputValue("")
}
}
const handleRemoveTag = (tagToRemove: string) => {
onChange(value.filter((tag) => tag !== tagToRemove))
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault()
handleAddTag()
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
handleRemoveTag(value[value.length - 1])
}
}
return (
<div
className={cn(
"flex flex-wrap items-center gap-2 rounded-md border border-neutral-700 bg-neutral-900 p-2 focus-within:border-orange-500 focus-within:ring-2 focus-within:ring-orange-500 focus-within:ring-offset-2",
className
)}
>
{value.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-orange-500/20 px-2 py-1 text-sm text-orange-500"
>
{tag}
{!disabled && (
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="rounded-full p-0.5 hover:bg-orange-500/30"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
<div className="flex flex-1 items-center">
<Input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleAddTag}
placeholder={value.length === 0 ? placeholder : ""}
disabled={disabled}
className="flex-1 border-0 bg-transparent px-0 py-1 text-white placeholder:text-neutral-500 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{inputValue.trim() && (
<button
type="button"
onClick={handleAddTag}
disabled={disabled}
className="ml-2 rounded-md p-1 text-neutral-500 hover:bg-neutral-800 hover:text-white"
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
</div>
)
}
export { TagInput }