feat: add workout form component with advanced test mocking

- Add WorkoutForm component for creating/editing workouts
- Implement tabbed interface (Basics, Timing, Content, Media)
- Add form validation and error handling
- Add sophisticated mock infrastructure for Supabase
- Include 32 test cases covering rendering, validation, and submission
- Setup dynamic mock functions for testing different scenarios
This commit is contained in:
Millian Lamiaux
2026-03-14 20:42:59 +01:00
parent 6adf709dce
commit 9dd1a4fe7c
2 changed files with 1324 additions and 0 deletions

View File

@@ -0,0 +1,805 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import WorkoutForm from './workout-form'
// Mock Next.js router
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
}))
// Create mock functions that can be manipulated per test
const mockSelect = vi.fn()
const mockOrder = vi.fn()
const mockInsert = vi.fn()
const mockInsertSelect = vi.fn()
const mockInsertSelectSingle = vi.fn()
const mockUpdate = vi.fn()
const mockUpdateEq = vi.fn()
const mockUpdateEqSelect = vi.fn()
const mockUpdateEqSelectSingle = vi.fn()
const mockFrom = vi.fn()
// Mock Supabase with dynamic mock functions
vi.mock('@/lib/supabase', () => ({
supabase: {
from: (...args: any[]) => mockFrom(...args),
storage: {
from: vi.fn(() => ({
move: vi.fn(() => Promise.resolve({ error: null })),
getPublicUrl: vi.fn(() => ({ data: { publicUrl: 'https://example.com/file.jpg' } })),
})),
},
},
}))
// Mock storage functions
const mockMoveFiles = vi.fn()
vi.mock('@/lib/storage', () => ({
moveFilesFromTempToWorkout: (...args: any[]) => mockMoveFiles(...args),
UPLOAD_CONFIGS: {
video: {
bucket: 'workout-videos',
maxSize: 500 * 1024 * 1024,
allowedTypes: ['video/mp4', 'video/webm', 'video/quicktime'],
},
thumbnail: {
bucket: 'workout-thumbnails',
maxSize: 10 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
avatar: {
bucket: 'trainer-avatars',
maxSize: 5 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
},
}))
// Helper to setup default successful mocks
const setupDefaultMocks = () => {
// Reset all mocks
mockSelect.mockReset()
mockOrder.mockReset()
mockInsert.mockReset()
mockInsertSelect.mockReset()
mockInsertSelectSingle.mockReset()
mockUpdate.mockReset()
mockUpdateEq.mockReset()
mockUpdateEqSelect.mockReset()
mockUpdateEqSelectSingle.mockReset()
mockFrom.mockReset()
mockMoveFiles.mockReset()
// Setup trainers query chain
mockOrder.mockResolvedValue({
data: [{ id: 'trainer-1', name: 'Test Trainer' }],
error: null,
})
mockSelect.mockReturnValue({ order: mockOrder })
// Setup insert chain for create mode
mockInsertSelectSingle.mockResolvedValue({
data: { id: 'new-workout-id' },
error: null,
})
mockInsertSelect.mockReturnValue({ single: mockInsertSelectSingle })
mockInsert.mockReturnValue({ select: mockInsertSelect })
// Setup update chain for edit mode
mockUpdateEqSelectSingle.mockResolvedValue({
data: { id: 'test-id' },
error: null,
})
mockUpdateEqSelect.mockReturnValue({ single: mockUpdateEqSelectSingle })
mockUpdateEq.mockReturnValue({ select: mockUpdateEqSelect })
mockUpdate.mockReturnValue({ eq: mockUpdateEq })
// Setup main from mock
mockFrom.mockImplementation((table: string) => {
if (table === 'trainers') {
return { select: mockSelect }
}
if (table === 'workouts') {
return {
select: mockSelect,
insert: mockInsert,
update: mockUpdate,
}
}
return {
select: mockSelect,
insert: mockInsert,
update: mockUpdate,
}
})
// Setup move files mock
mockMoveFiles.mockResolvedValue({
thumbnail: null,
video: null,
errors: [],
})
}
describe('WorkoutForm', () => {
beforeEach(() => {
setupDefaultMocks()
mockPush.mockClear()
})
describe('rendering', () => {
it('should render form in create mode', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument()
})
expect(screen.getByRole('tab', { name: /basics/i })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /content/i })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /media/i })).toBeInTheDocument()
})
it('should render form in edit mode', async () => {
const initialData = {
id: 'test-id',
title: 'Test Workout',
trainer_id: 'trainer-1',
category: 'full-body' as const,
level: 'Beginner' as const,
duration: 4,
rounds: 8,
prep_time: 10,
work_time: 20,
rest_time: 10,
calories: 45,
equipment: [],
music_vibe: 'electronic' as const,
exercises: [{ name: 'Push-ups', duration: 20 }],
thumbnail_url: null,
video_url: null,
is_featured: false,
created_at: '2024-01-01',
updated_at: '2024-01-01',
}
render(<WorkoutForm mode="edit" initialData={initialData} />)
await waitFor(() => {
expect(screen.getByDisplayValue('Test Workout')).toBeInTheDocument()
})
})
it('should show loading state for trainers initially', async () => {
render(<WorkoutForm mode="create" />)
expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument()
})
})
describe('form fields', () => {
it('should update title field', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument()
})
const titleInput = screen.getByLabelText(/workout title/i)
await userEvent.type(titleInput, 'My Workout')
expect(titleInput).toHaveValue('My Workout')
})
it('should toggle featured workout', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('switch')).toBeInTheDocument()
})
const toggle = screen.getByRole('switch')
expect(toggle).toHaveAttribute('aria-checked', 'false')
await userEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-checked', 'true')
})
})
describe('tab navigation', () => {
it('should navigate to Timing tab', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /timing/i }))
expect(screen.getByRole('tab', { name: /timing/i })).toHaveAttribute('data-state', 'active')
})
it('should navigate to Content tab', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /content/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /content/i }))
expect(screen.getByRole('tab', { name: /content/i })).toHaveAttribute('data-state', 'active')
expect(screen.getByText(/add exercise/i)).toBeInTheDocument()
})
it('should navigate to Media tab', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /media/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /media/i }))
expect(screen.getByRole('tab', { name: /media/i })).toHaveAttribute('data-state', 'active')
})
it('should show music vibe in Media tab', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /media/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /media/i }))
expect(screen.getByLabelText(/music vibe/i)).toBeInTheDocument()
})
it('should persist data when switching tabs', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument()
})
await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout')
await userEvent.click(screen.getByRole('tab', { name: /timing/i }))
await userEvent.click(screen.getByRole('tab', { name: /basics/i }))
expect(screen.getByDisplayValue('Test Workout')).toBeInTheDocument()
})
})
describe('validation', () => {
it('should show title validation error', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('button', { name: /create workout/i })).toBeInTheDocument()
})
const submitButton = screen.getByRole('button', { name: /create workout/i })
await userEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText(/title is required/i)).toBeInTheDocument()
})
})
it('should clear validation error when field is filled', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('button', { name: /create workout/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('button', { name: /create workout/i }))
await waitFor(() => {
expect(screen.getByText(/title is required/i)).toBeInTheDocument()
}, { timeout: 3000 })
await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout')
expect(screen.getByDisplayValue('Test Workout')).toBeInTheDocument()
})
})
describe('cancel action', () => {
it('should navigate back on cancel in create mode', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
const cancelButton = screen.getByRole('button', { name: /cancel/i })
await userEvent.click(cancelButton)
expect(mockPush).toHaveBeenCalledWith('/workouts')
})
it('should navigate to workout detail on cancel in edit mode', async () => {
const initialData = {
id: 'test-id',
title: 'Test Workout',
trainer_id: 'trainer-1',
category: 'full-body' as const,
level: 'Beginner' as const,
duration: 4,
rounds: 8,
prep_time: 10,
work_time: 20,
rest_time: 10,
calories: 45,
equipment: [],
music_vibe: 'electronic' as const,
exercises: [{ name: 'Push-ups', duration: 20 }],
thumbnail_url: null,
video_url: null,
is_featured: false,
created_at: '2024-01-01',
updated_at: '2024-01-01',
}
render(<WorkoutForm mode="edit" initialData={initialData} />)
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
const cancelButton = screen.getByRole('button', { name: /cancel/i })
await userEvent.click(cancelButton)
expect(mockPush).toHaveBeenCalledWith('/workouts/test-id')
})
})
describe('timing fields', () => {
it('should update rounds field', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /timing/i }))
const roundsInput = screen.getByLabelText(/rounds/i)
await userEvent.clear(roundsInput)
await userEvent.type(roundsInput, '12')
expect(roundsInput).toHaveValue(12)
})
it('should update calories field', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /timing/i }))
const caloriesInput = screen.getByLabelText(/calories/i)
await userEvent.clear(caloriesInput)
await userEvent.type(caloriesInput, '60')
expect(caloriesInput).toHaveValue(60)
})
it('should update prep time field', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /timing/i }))
const prepInput = screen.getByLabelText(/prep time/i)
await userEvent.clear(prepInput)
await userEvent.type(prepInput, '15')
expect(prepInput).toHaveValue(15)
})
it('should update work time field', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /timing/i }))
const workInput = screen.getByLabelText(/work time/i)
await userEvent.clear(workInput)
await userEvent.type(workInput, '30')
expect(workInput).toHaveValue(30)
})
it('should update rest time field', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /timing/i }))
const restInput = screen.getByLabelText(/rest time/i)
await userEvent.clear(restInput)
await userEvent.type(restInput, '15')
expect(restInput).toHaveValue(15)
})
})
describe('default values', () => {
it('should have correct default timing values', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /timing/i }))
const inputs = screen.getAllByRole('spinbutton')
const values = inputs.map(input => (input as HTMLInputElement).value)
expect(values).toContain('10')
expect(values).toContain('20')
})
})
describe('select fields', () => {
it('should change category', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByLabelText(/category/i)).toBeInTheDocument()
})
const categorySelect = screen.getByLabelText(/category/i)
await userEvent.click(categorySelect)
})
it('should change level', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByLabelText(/level/i)).toBeInTheDocument()
})
const levelSelect = screen.getByLabelText(/level/i)
await userEvent.click(levelSelect)
})
it('should change duration', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /timing/i }))
const durationSelect = screen.getByLabelText(/duration/i)
await userEvent.click(durationSelect)
})
it('should change music vibe', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /media/i })).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('tab', { name: /media/i }))
const musicVibeSelect = screen.getByLabelText(/music vibe/i)
await userEvent.click(musicVibeSelect)
})
})
describe('form submission', () => {
it('should validate all required fields on submit', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByRole('button', { name: /create workout/i })).toBeInTheDocument()
})
// Try to submit without filling any fields
await userEvent.click(screen.getByRole('button', { name: /create workout/i }))
// Should show validation errors
await waitFor(() => {
expect(screen.getByText(/title is required/i)).toBeInTheDocument()
})
// Check for other validation errors that should appear
const errors = screen.queryAllByText(/is required/i)
expect(errors.length).toBeGreaterThan(0)
})
it('should handle trainer selection error gracefully', async () => {
const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {})
// Mock trainers fetch to fail
mockOrder.mockResolvedValue({
data: null,
error: { message: 'Database error' }
})
render(<WorkoutForm mode="create" />)
// Wait a bit for the error to be logged
await new Promise(resolve => setTimeout(resolve, 100))
// Should have logged the error
await waitFor(() => {
expect(consoleErrorMock).toHaveBeenCalledWith('Failed to load trainers:', expect.any(Object))
})
consoleErrorMock.mockRestore()
})
it('should successfully submit form in create mode', async () => {
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument()
})
// Fill in required fields
await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout')
// Select trainer
await userEvent.click(screen.getByLabelText(/trainer/i))
await userEvent.click(screen.getByText('Test Trainer'))
const submitButton = screen.getByRole('button', { name: /create workout/i })
await userEvent.click(submitButton)
// Should show loading state
await waitFor(() => {
expect(screen.getByText(/saving/i)).toBeInTheDocument()
})
// Verify insert was called
await waitFor(() => {
expect(mockInsert).toHaveBeenCalled()
})
// Should navigate after success
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/workouts/new-workout-id')
})
})
it('should successfully submit form in edit mode', async () => {
const initialData = {
id: 'test-id',
title: 'Test Workout',
trainer_id: 'trainer-1',
category: 'full-body' as const,
level: 'Beginner' as const,
duration: 4,
rounds: 8,
prep_time: 10,
work_time: 20,
rest_time: 10,
calories: 45,
equipment: [],
music_vibe: 'electronic' as const,
exercises: [{ name: 'Push-ups', duration: 20 }],
thumbnail_url: null,
video_url: null,
is_featured: false,
created_at: '2024-01-01',
updated_at: '2024-01-01',
}
render(<WorkoutForm mode="edit" initialData={initialData} />)
await waitFor(() => {
expect(screen.getByRole('button', { name: /update workout/i })).toBeInTheDocument()
})
const submitButton = screen.getByRole('button', { name: /update workout/i })
await userEvent.click(submitButton)
// Should show loading state
await waitFor(() => {
expect(screen.getByText(/saving/i)).toBeInTheDocument()
})
// Verify update was called
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalled()
})
// Should navigate after success
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/workouts/test-id')
})
})
it('should handle file movement during create', async () => {
// Setup mock to return files that need to be moved
mockMoveFiles.mockResolvedValue({
thumbnail: {
oldPath: 'temp/thumb.jpg',
newPath: 'new-workout-id/thumb.jpg',
newUrl: 'https://example.com/new-thumb.jpg'
},
video: {
oldPath: 'temp/video.mp4',
newPath: 'new-workout-id/video.mp4',
newUrl: 'https://example.com/new-video.mp4'
},
errors: []
})
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument()
})
// Fill in required fields
await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout')
// Select trainer
await userEvent.click(screen.getByLabelText(/trainer/i))
await userEvent.click(screen.getByText('Test Trainer'))
const submitButton = screen.getByRole('button', { name: /create workout/i })
await userEvent.click(submitButton)
// Verify moveFiles was called
await waitFor(() => {
expect(mockMoveFiles).toHaveBeenCalled()
})
// Verify update was called to update URLs
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalled()
})
})
it('should handle file movement errors', async () => {
const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {})
// Setup mock to return errors
mockMoveFiles.mockResolvedValue({
thumbnail: null,
video: null,
errors: ['Failed to move thumbnail', 'Failed to move video']
})
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument()
})
// Fill in required fields
await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout')
// Select trainer
await userEvent.click(screen.getByLabelText(/trainer/i))
await userEvent.click(screen.getByText('Test Trainer'))
const submitButton = screen.getByRole('button', { name: /create workout/i })
await userEvent.click(submitButton)
// Verify warning was logged
await waitFor(() => {
expect(consoleWarnMock).toHaveBeenCalledWith(
'Some files could not be moved:',
['Failed to move thumbnail', 'Failed to move video']
)
})
consoleWarnMock.mockRestore()
})
it('should handle submission error', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {})
// Setup insert to fail
mockInsertSelectSingle.mockResolvedValue({
data: null,
error: { message: 'Database error' }
})
render(<WorkoutForm mode="create" />)
await waitFor(() => {
expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument()
})
// Fill in required fields
await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout')
// Select trainer
await userEvent.click(screen.getByLabelText(/trainer/i))
await userEvent.click(screen.getByText('Test Trainer'))
const submitButton = screen.getByRole('button', { name: /create workout/i })
await userEvent.click(submitButton)
// Should show alert on error
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith('Failed to save workout. Please try again.')
})
alertMock.mockRestore()
})
it('should handle update error in edit mode', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {})
const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {})
// Setup update to fail
mockUpdateEqSelectSingle.mockResolvedValue({
data: null,
error: { message: 'Update failed' }
})
const initialData = {
id: 'test-id',
title: 'Test Workout',
trainer_id: 'trainer-1',
category: 'full-body' as const,
level: 'Beginner' as const,
duration: 4,
rounds: 8,
prep_time: 10,
work_time: 20,
rest_time: 10,
calories: 45,
equipment: [],
music_vibe: 'electronic' as const,
exercises: [{ name: 'Push-ups', duration: 20 }],
thumbnail_url: null,
video_url: null,
is_featured: false,
created_at: '2024-01-01',
updated_at: '2024-01-01',
}
render(<WorkoutForm mode="edit" initialData={initialData} />)
await waitFor(() => {
expect(screen.getByRole('button', { name: /update workout/i })).toBeInTheDocument()
})
const submitButton = screen.getByRole('button', { name: /update workout/i })
await userEvent.click(submitButton)
// Should show alert on error
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith('Failed to save workout. Please try again.')
})
alertMock.mockRestore()
consoleErrorMock.mockRestore()
})
})
})

View File

@@ -0,0 +1,519 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { Loader2, Save, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { supabase } from "@/lib/supabase"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Select } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { TagInput } from "@/components/tag-input"
import { ExerciseList, Exercise } from "@/components/exercise-list"
import { MediaUpload } from "@/components/media-upload"
import { moveFilesFromTempToWorkout } from "@/lib/storage"
import type { Database } from "@/lib/supabase"
type Workout = Database["public"]["Tables"]["workouts"]["Row"]
interface WorkoutFormProps {
initialData?: Workout
mode?: "create" | "edit"
}
const CATEGORY_OPTIONS = [
{ value: "full-body", label: "Full Body" },
{ value: "core", label: "Core" },
{ value: "upper-body", label: "Upper Body" },
{ value: "lower-body", label: "Lower Body" },
{ value: "cardio", label: "Cardio" },
]
const LEVEL_OPTIONS = [
{ value: "Beginner", label: "Beginner" },
{ value: "Intermediate", label: "Intermediate" },
{ value: "Advanced", label: "Advanced" },
]
const DURATION_OPTIONS = [
{ value: "4", label: "4 minutes" },
{ value: "8", label: "8 minutes" },
{ value: "12", label: "12 minutes" },
{ value: "20", label: "20 minutes" },
]
const MUSIC_VIBE_OPTIONS = [
{ value: "electronic", label: "Electronic" },
{ value: "hip-hop", label: "Hip Hop" },
{ value: "pop", label: "Pop" },
{ value: "rock", label: "Rock" },
{ value: "chill", label: "Chill" },
]
export default function WorkoutForm({ initialData, mode = "create" }: WorkoutFormProps) {
const router = useRouter()
const [isLoading, setIsLoading] = React.useState(false)
const [errors, setErrors] = React.useState<Record<string, string>>({})
const [trainers, setTrainers] = React.useState<{ id: string; name: string }[]>([])
const [isLoadingTrainers, setIsLoadingTrainers] = React.useState(true)
// Form state
const [title, setTitle] = React.useState(initialData?.title || "")
const [trainerId, setTrainerId] = React.useState(initialData?.trainer_id || "")
const [category, setCategory] = React.useState(initialData?.category || "full-body")
const [level, setLevel] = React.useState(initialData?.level || "Beginner")
const [isFeatured, setIsFeatured] = React.useState(initialData?.is_featured || false)
const [duration, setDuration] = React.useState(String(initialData?.duration || "4"))
const [rounds, setRounds] = React.useState(String(initialData?.rounds || "8"))
const [prepTime, setPrepTime] = React.useState(String(initialData?.prep_time || "10"))
const [workTime, setWorkTime] = React.useState(String(initialData?.work_time || "20"))
const [restTime, setRestTime] = React.useState(String(initialData?.rest_time || "10"))
const [calories, setCalories] = React.useState(String(initialData?.calories || "45"))
const [exercises, setExercises] = React.useState<Exercise[]>(
initialData?.exercises?.length
? initialData.exercises
: [{ name: "", duration: 20 }]
)
const [equipment, setEquipment] = React.useState<string[]>(initialData?.equipment || [])
const [musicVibe, setMusicVibe] = React.useState(initialData?.music_vibe || "electronic")
const [thumbnailUrl, setThumbnailUrl] = React.useState(initialData?.thumbnail_url || "")
const [videoUrl, setVideoUrl] = React.useState(initialData?.video_url || "")
// Load trainers
React.useEffect(() => {
const loadTrainers = async () => {
try {
const { data, error } = await supabase
.from("trainers")
.select("id, name")
.order("name")
if (error) throw error
setTrainers(data || [])
} catch (err) {
console.error("Failed to load trainers:", err)
} finally {
setIsLoadingTrainers(false)
}
}
loadTrainers()
}, [])
const trainerOptions = trainers.map((t) => ({ value: t.id, label: t.name }))
const validate = () => {
const newErrors: Record<string, string> = {}
if (!title.trim()) newErrors.title = "Title is required"
if (!trainerId) newErrors.trainer_id = "Trainer is required"
if (!category) newErrors.category = "Category is required"
if (!level) newErrors.level = "Level is required"
if (!duration) newErrors.duration = "Duration is required"
if (!rounds) newErrors.rounds = "Rounds is required"
if (!prepTime) newErrors.prep_time = "Prep time is required"
if (!workTime) newErrors.work_time = "Work time is required"
if (!restTime) newErrors.rest_time = "Rest time is required"
if (!calories) newErrors.calories = "Calories is required"
if (!musicVibe) newErrors.music_vibe = "Music vibe is required"
if (exercises.some((e) => !e.name.trim())) {
newErrors.exercises = "All exercises must have a name"
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) {
return
}
setIsLoading(true)
try {
const workoutData = {
title: title.trim(),
trainer_id: trainerId,
category: category as Workout["category"],
level: level as Workout["level"],
duration: parseInt(duration),
rounds: parseInt(rounds),
prep_time: parseInt(prepTime),
work_time: parseInt(workTime),
rest_time: parseInt(restTime),
calories: parseInt(calories),
equipment,
exercises,
music_vibe: musicVibe as Workout["music_vibe"],
thumbnail_url: thumbnailUrl.trim() || null,
video_url: videoUrl.trim() || null,
is_featured: isFeatured,
}
let result
if (mode === "edit" && initialData) {
result = await supabase
.from("workouts")
.update(workoutData)
.eq("id", initialData.id)
.select()
.single()
} else {
result = await supabase
.from("workouts")
.insert(workoutData)
.select()
.single()
}
if (result.error) throw result.error
// If creating new workout and files were uploaded to temp folder, move them
if (mode === "create" && result.data) {
const moveResult = await moveFilesFromTempToWorkout(
"temp",
result.data.id,
thumbnailUrl,
videoUrl
)
// Update workout with new URLs if files were moved
if (moveResult.thumbnail || moveResult.video) {
const updateData: Partial<Workout> = {}
if (moveResult.thumbnail) {
updateData.thumbnail_url = moveResult.thumbnail.newUrl
}
if (moveResult.video) {
updateData.video_url = moveResult.video.newUrl
}
if (Object.keys(updateData).length > 0) {
await supabase
.from("workouts")
.update(updateData)
.eq("id", result.data.id)
}
}
// Show warnings if there were errors
if (moveResult.errors.length > 0) {
console.warn("Some files could not be moved:", moveResult.errors)
// Optionally show a toast notification here
}
}
router.push(`/workouts/${result.data.id}`)
} catch (err) {
console.error("Failed to save workout:", err)
alert("Failed to save workout. Please try again.")
} finally {
setIsLoading(false)
}
}
const handleCancel = () => {
if (mode === "edit" && initialData) {
router.push(`/workouts/${initialData.id}`)
} else {
router.push("/workouts")
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<Tabs defaultValue="basics" className="w-full">
<TabsList className="grid w-full grid-cols-4 bg-neutral-900">
<TabsTrigger value="basics" className="data-[state=active]:bg-neutral-800">
Basics
</TabsTrigger>
<TabsTrigger value="timing" className="data-[state=active]:bg-neutral-800">
Timing
</TabsTrigger>
<TabsTrigger value="content" className="data-[state=active]:bg-neutral-800">
Content
</TabsTrigger>
<TabsTrigger value="media" className="data-[state=active]:bg-neutral-800">
Media
</TabsTrigger>
</TabsList>
{/* Tab 1: Basics */}
<TabsContent value="basics" className="space-y-4">
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="title">Workout Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Full Body Ignite"
className={cn(errors.title && "border-red-500")}
/>
{errors.title && <p className="text-xs text-red-500">{errors.title}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="trainer">Trainer *</Label>
{isLoadingTrainers ? (
<div className="h-10 rounded-md border border-neutral-700 bg-neutral-900 px-3 py-2 text-sm text-neutral-500">
Loading trainers...
</div>
) : (
<>
<Select
id="trainer"
value={trainerId}
onValueChange={(value) => setTrainerId(value)}
options={trainerOptions}
placeholder="Select a trainer"
/>
{errors.trainer_id && (
<p className="text-xs text-red-500">{errors.trainer_id}</p>
)}
</>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="category">Category *</Label>
<Select
id="category"
value={category}
onValueChange={(value) => setCategory(value as typeof category)}
options={CATEGORY_OPTIONS}
/>
{errors.category && <p className="text-xs text-red-500">{errors.category}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="level">Level *</Label>
<Select
id="level"
value={level}
onValueChange={(value) => setLevel(value as typeof level)}
options={LEVEL_OPTIONS}
/>
{errors.level && <p className="text-xs text-red-500">{errors.level}</p>}
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-neutral-800 bg-neutral-900/50 p-4">
<div className="space-y-0.5">
<Label htmlFor="featured" className="text-base">
Featured Workout
</Label>
<p className="text-sm text-neutral-500">
Show this workout in the featured section
</p>
</div>
<Switch id="featured" checked={isFeatured} onCheckedChange={setIsFeatured} />
</div>
</div>
</TabsContent>
{/* Tab 2: Timing */}
<TabsContent value="timing" className="space-y-4">
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="duration">Duration (minutes) *</Label>
<Select
id="duration"
value={duration}
onValueChange={(value) => setDuration(value)}
options={DURATION_OPTIONS}
/>
{errors.duration && <p className="text-xs text-red-500">{errors.duration}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="rounds">Total Rounds *</Label>
<Input
id="rounds"
type="number"
value={rounds}
onChange={(e) => setRounds(e.target.value)}
min={1}
className={cn(errors.rounds && "border-red-500")}
/>
{errors.rounds && <p className="text-xs text-red-500">{errors.rounds}</p>}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="prepTime">Prep Time (sec) *</Label>
<Input
id="prepTime"
type="number"
value={prepTime}
onChange={(e) => setPrepTime(e.target.value)}
min={0}
className={cn(errors.prep_time && "border-red-500")}
/>
{errors.prep_time && <p className="text-xs text-red-500">{errors.prep_time}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="workTime">Work Time (sec) *</Label>
<Input
id="workTime"
type="number"
value={workTime}
onChange={(e) => setWorkTime(e.target.value)}
min={1}
className={cn(errors.work_time && "border-red-500")}
/>
{errors.work_time && <p className="text-xs text-red-500">{errors.work_time}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="restTime">Rest Time (sec) *</Label>
<Input
id="restTime"
type="number"
value={restTime}
onChange={(e) => setRestTime(e.target.value)}
min={0}
className={cn(errors.rest_time && "border-red-500")}
/>
{errors.rest_time && <p className="text-xs text-red-500">{errors.rest_time}</p>}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="calories">Estimated Calories *</Label>
<Input
id="calories"
type="number"
value={calories}
onChange={(e) => setCalories(e.target.value)}
min={0}
placeholder="e.g., 45"
className={cn(errors.calories && "border-red-500")}
/>
{errors.calories && <p className="text-xs text-red-500">{errors.calories}</p>}
</div>
</div>
</TabsContent>
{/* Tab 3: Content */}
<TabsContent value="content" className="space-y-4">
<div className="space-y-4">
<div className="space-y-2">
<Label>Exercises *</Label>
<p className="text-sm text-neutral-500">
Add all exercises in order. Drag to reorder.
</p>
<ExerciseList
value={exercises}
onChange={setExercises}
workTimeDefault={parseInt(workTime) || 20}
/>
{errors.exercises && <p className="text-xs text-red-500">{errors.exercises}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="equipment">Equipment</Label>
<p className="text-sm text-neutral-500">
List all equipment needed (or leave empty for bodyweight)
</p>
<TagInput
id="equipment"
value={equipment}
onChange={setEquipment}
placeholder="Type equipment and press Enter..."
/>
</div>
</div>
</TabsContent>
{/* Tab 4: Media */}
<TabsContent value="media" className="space-y-6">
<div className="grid gap-6">
<div className="space-y-2">
<Label htmlFor="musicVibe">Music Vibe *</Label>
<Select
id="musicVibe"
value={musicVibe}
onValueChange={(value) => setMusicVibe(value as typeof musicVibe)}
options={MUSIC_VIBE_OPTIONS}
/>
{errors.music_vibe && <p className="text-xs text-red-500">{errors.music_vibe}</p>}
</div>
<div className="space-y-2">
<Label>Thumbnail</Label>
<MediaUpload
type="thumbnail"
workoutId={mode === "edit" && initialData ? initialData.id : undefined}
value={thumbnailUrl}
onChange={setThumbnailUrl}
disabled={isLoading}
/>
<p className="text-xs text-neutral-500">
Upload a thumbnail image for this workout
</p>
</div>
<div className="space-y-2">
<Label>Video</Label>
<MediaUpload
type="video"
workoutId={mode === "edit" && initialData ? initialData.id : undefined}
value={videoUrl}
onChange={setVideoUrl}
disabled={isLoading}
/>
<p className="text-xs text-neutral-500">
Upload the workout video
</p>
</div>
</div>
</TabsContent>
</Tabs>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-800">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isLoading}
className="border-neutral-700 text-neutral-400 hover:bg-neutral-800 hover:text-white"
>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button
type="submit"
disabled={isLoading}
className="bg-orange-500 hover:bg-orange-600"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{mode === "edit" ? "Update Workout" : "Create Workout"}
</>
)}
</Button>
</div>
</form>
)
}