Files
euchre_camp/src/components/EditTournamentForm.tsx
T
david bb6be245b7
Release / release (push) Failing after 9s
Build CI Images / build-ci-base (push) Failing after 18s
feat: Implement tournament schedule tab and fix E2E tests (#27)
## Summary

This PR implements the tournament schedule tab functionality and fixes all remaining E2E test failures.

### Changes Included

1. **Tournament Schedule Feature**
   - Added tournament schedule page at `/admin/tournaments/[id]/schedule`
   - Implemented "Generate Schedule" button functionality
   - Added schedule generation logic for round-robin tournaments

2. **E2E Test Fixes**
   - Fixed database connection issues in production builds
   - Improved test reliability with better error handling and debugging
   - Updated test infrastructure to use environment variables instead of hardcoded values

3. **CI/CD Updates**
   - Added E2E test job to PR workflow
   - Configured tests to run against development database
   - Moved database password to Gitea secrets

4. **Code Quality**
   - Removed hardcoded passwords from codebase
   - Improved Prisma client configuration
   - Enhanced authentication and navigation components

### Test Results
All 16 E2E test scenarios are now passing:
- Authentication tests: 
- Registration tests: 
- Tournament schedule tests: 
- Player schedule tests: 
- Admin navigation tests: 

### Database Configuration
- Tests run against `euchre_camp_dev` database
- Production builds use environment variables for database configuration
- Database password stored in Gitea secrets as `DB_PASSWORD`

### CI Pipeline
The PR workflow now includes:
1. Unit tests
2. E2E tests (using production build)
3. Version bump analysis

E2E tests must pass before PR can be merged.

Reviewed-on: #27
Co-authored-by: David Gwilliam <dhgwilliam@gmail.com>
Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
2026-04-27 01:59:16 +00:00

389 lines
14 KiB
TypeScript

"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import type { Event } from "@prisma/client"
interface EditTournamentFormProps {
tournament: Event
}
export default function EditTournamentForm({ tournament }: EditTournamentFormProps) {
const router = useRouter()
const [formData, setFormData] = useState({
name: tournament.name,
description: tournament.description || "",
eventDate: tournament.eventDate ? tournament.eventDate.toISOString().split('T')[0] : "",
eventType: tournament.eventType,
format: tournament.format,
status: tournament.status,
maxParticipants: tournament.maxParticipants?.toString() || "",
targetScore: tournament.targetScore?.toString() || "",
allowTies: tournament.allowTies || false,
teamDurability: tournament.teamDurability || "permanent",
partnerRotation: tournament.partnerRotation || "none",
allowByes: tournament.allowByes ?? true,
})
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [isLoading, setIsLoading] = useState(false)
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target
const checked = (e.target as HTMLInputElement).checked
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setSuccess("")
setIsLoading(true)
try {
const response = await fetch(`/api/tournaments/${tournament.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...formData,
maxParticipants: formData.maxParticipants ? parseInt(formData.maxParticipants) : null,
eventDate: formData.eventDate ? new Date(formData.eventDate).toISOString() : null,
targetScore: formData.targetScore ? parseInt(formData.targetScore) : null,
allowTies: formData.allowTies,
}),
})
if (!response.ok) {
try {
const data = await response.json()
setError(data.error || "Failed to update tournament")
} catch {
setError(`Failed to update tournament: ${response.status} ${response.statusText}`)
}
setIsLoading(false)
return
}
setSuccess("Tournament updated successfully!")
setIsLoading(false)
// Redirect to tournament detail after 2 seconds
setTimeout(() => {
router.push(`/admin/tournaments/${tournament.id}`)
}, 2000)
} catch {
setError("An error occurred. Please try again.")
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{success && (
<div className="rounded-md bg-green-50 p-4">
<div className="text-sm text-green-700">{success}</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Tournament Name
</label>
<input
type="text"
id="name"
name="name"
required
value={formData.name}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="eventDate" className="block text-sm font-medium text-gray-700">
Date
</label>
<input
type="date"
id="eventDate"
name="eventDate"
value={formData.eventDate}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
id="description"
name="description"
rows={3}
value={formData.description}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label htmlFor="eventType" className="block text-sm font-medium text-gray-700">
Event Type
</label>
<select
id="eventType"
name="eventType"
value={formData.eventType}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="tournament">Tournament</option>
<option value="league">League</option>
<option value="casual">Casual Play</option>
</select>
</div>
<div>
<label htmlFor="format" className="block text-sm font-medium text-gray-700">
Format
</label>
<select
id="format"
name="format"
value={formData.format}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="round_robin">Round Robin</option>
<option value="single_elimination">Single Elimination</option>
<option value="double_elimination">Double Elimination</option>
<option value="swiss">Swiss</option>
</select>
</div>
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700">
Status
</label>
<select
id="status"
name="status"
value={formData.status}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
>
<option value="planned">Planned</option>
<option value="registration">Registration Open</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="maxParticipants" className="block text-sm font-medium text-gray-700">
Max Participants (optional)
</label>
<input
type="number"
id="maxParticipants"
name="maxParticipants"
min="1"
value={formData.maxParticipants}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="targetScore" className="block text-sm font-medium text-gray-700">
Target Score (optional)
</label>
<input
type="number"
id="targetScore"
name="targetScore"
min="1"
value={formData.targetScore}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
placeholder="Default: 5"
/>
</div>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
name="allowTies"
checked={formData.allowTies}
onChange={handleChange}
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Allow Ties</span>
</label>
</div>
{/* Team Configuration - shown for round_robin format */}
{formData.format === 'round_robin' && (
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
<h3 className="text-sm font-medium text-gray-700">Team Configuration</h3>
{/* Team Durability */}
<div>
<label className="block text-sm font-medium text-gray-600 mb-2">
Team Formation Strategy
</label>
<div className="flex gap-4 flex-wrap">
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="permanent"
checked={formData.teamDurability === 'permanent'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">Fixed Teams</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="variable"
checked={formData.teamDurability === 'variable'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">Pre-Planned Variable</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="teamDurability"
value="per_round"
checked={formData.teamDurability === 'per_round'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">Dynamic/Progressive</span>
</label>
</div>
<p className="text-xs text-gray-500 mt-1">
{formData.teamDurability === 'permanent'
? 'Teams formed once and stay fixed throughout the tournament.'
: formData.teamDurability === 'variable'
? 'Fresh teams each round with partner rotation. Schedule is pre-planned.'
: 'Teams formed based on results. Schedule progresses as rounds complete.'}
</p>
</div>
{/* Partner Rotation - only for variable/per_round teams */}
{(formData.teamDurability === 'variable' || formData.teamDurability === 'per_round') && (
<div>
<label className="block text-sm font-medium text-gray-600 mb-2">
Partner Rotation Strategy
</label>
<div className="flex gap-4 flex-wrap">
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="none"
checked={formData.partnerRotation === 'none'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">None (Random)</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="minimize_repeat"
checked={formData.partnerRotation === 'minimize_repeat'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">Minimize Repeat</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="maximize_even"
checked={formData.partnerRotation === 'maximize_even'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">Maximize Even</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="partnerRotation"
value="elo_based"
checked={formData.partnerRotation === 'elo_based'}
onChange={handleChange}
className="mr-2"
/>
<span className="text-sm">ELO-Based</span>
</label>
</div>
</div>
)}
{/* Allow Byes */}
<div>
<label className="flex items-center">
<input
type="checkbox"
name="allowByes"
checked={formData.allowByes}
onChange={(e) => setFormData(prev => ({ ...prev, allowByes: e.target.checked }))}
className="mr-2"
/>
<span className="text-sm font-medium text-gray-600">Allow Byes (for odd number of participants)</span>
</label>
</div>
</div>
)}
<div className="flex justify-end space-x-4">
<Link
href={`/admin/tournaments/${tournament.id}`}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
</Link>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:opacity-50"
>
{isLoading ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
)
}