diff --git a/admin-web/components/exercise-list.test.tsx b/admin-web/components/exercise-list.test.tsx new file mode 100644 index 0000000..4a3f04c --- /dev/null +++ b/admin-web/components/exercise-list.test.tsx @@ -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( + + ) + + expect(screen.getByText('Add Exercise')).toBeInTheDocument() + }) + + it('should add new exercise on button click', async () => { + const user = userEvent.setup() + + render( + + ) + + 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( + + ) + + const deleteButton = screen.getByTestId('delete-exercise-0') + await user.click(deleteButton) + + expect(mockOnChange).toHaveBeenCalledWith([ + { name: 'Squats', duration: 20 }, + ]) + }) + + it('should update exercise name', () => { + render( + + ) + + // 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( + + ) + + // 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( + + ) + + const deleteButton = screen.getByTestId('delete-exercise-0') + expect(deleteButton).toBeDisabled() + }) + + it('should use workTimeDefault for new exercises', async () => { + const user = userEvent.setup() + + render( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + const upButton = screen.getByTestId('move-up-0') + expect(upButton).toBeDisabled() + }) + + it('should disable move down on last item', () => { + render( + + ) + + const downButton = screen.getByTestId('move-down-1') + expect(downButton).toBeDisabled() + }) + + it('should respect disabled prop', () => { + render( + + ) + + 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( + + ) + + // 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') + }) +}) diff --git a/admin-web/components/exercise-list.tsx b/admin-web/components/exercise-list.tsx new file mode 100644 index 0000000..010449a --- /dev/null +++ b/admin-web/components/exercise-list.tsx @@ -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 ( +
+
+ {value.map((exercise, index) => ( +
+
+ + +
+ +
+
+ + handleUpdate(index, "name", e.target.value)} + placeholder="e.g., Jumping Jacks" + disabled={disabled} + className="h-9" + data-testid={`exercise-name-${index}`} + /> +
+ +
+ +
+ + handleUpdate(index, "duration", parseInt(e.target.value) || 0) + } + disabled={disabled} + className="h-9" + min={1} + data-testid={`exercise-duration-${index}`} + /> + s +
+
+
+ + +
+ ))} +
+ + +
+ ) +} + +export { ExerciseList } +export type { Exercise } diff --git a/admin-web/components/tag-input.test.tsx b/admin-web/components/tag-input.test.tsx new file mode 100644 index 0000000..925d8f9 --- /dev/null +++ b/admin-web/components/tag-input.test.tsx @@ -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( + + ) + + expect(screen.getByPlaceholderText('Add equipment...')).toBeInTheDocument() + }) + + it('should add tag on Enter key', async () => { + const user = userEvent.setup() + + render( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + // 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( + + ) + + 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( + + ) + + const input = screen.getByRole('textbox') + await user.type(input, 'Yoga') + await user.keyboard('{Backspace}') + + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should display all tags', () => { + render( + + ) + + 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( + + ) + + const input = screen.getByRole('textbox') + await user.type(input, ' Dumbbells ') + await user.keyboard('{Enter}') + + expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells']) + }) + + it('should respect disabled prop', () => { + render( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + // Add second tag + await user.type(input, 'Yoga Mat') + await user.keyboard('{Enter}') + expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells', 'Yoga Mat']) + + // Rerender with updated value + rerender( + + ) + + // Add third tag + await user.type(input, 'Resistance Band') + await user.keyboard('{Enter}') + expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells', 'Yoga Mat', 'Resistance Band']) + }) +}) diff --git a/admin-web/components/tag-input.tsx b/admin-web/components/tag-input.tsx new file mode 100644 index 0000000..c149303 --- /dev/null +++ b/admin-web/components/tag-input.tsx @@ -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(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) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddTag() + } else if (e.key === "Backspace" && !inputValue && value.length > 0) { + handleRemoveTag(value[value.length - 1]) + } + } + + return ( +
+ {value.map((tag) => ( + + {tag} + {!disabled && ( + + )} + + ))} +
+ 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() && ( + + )} +
+
+ ) +} + +export { TagInput }