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:
805
admin-web/components/workout-form.test.tsx
Normal file
805
admin-web/components/workout-form.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
519
admin-web/components/workout-form.tsx
Normal file
519
admin-web/components/workout-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user