Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 196 additions & 64 deletions apps/wodsmith-start/src/components/divisions/organizer-division-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ import {
extractClosestEdge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box"
import { ChevronDown, GripVertical, Trash2, Users } from "lucide-react"
import {
AlertTriangle,
ChevronDown,
GripVertical,
Trash2,
Users,
} from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
Expand All @@ -24,25 +30,35 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/utils/cn"

export interface OrganizerDivisionItemProps {
id: string
label: string
description: string | null
maxSpots: number | null
defaultMaxSpots: number | null
teamSize: number
index: number
registrationCount: number
isOnly: boolean
instanceId: symbol
onLabelSave: (value: string) => void
onTeamSizeSave: (value: number) => void
onDescriptionSave: (value: string | null) => void
onMaxSpotsSave: (value: number | null) => void
onRemove: () => void
Expand All @@ -55,11 +71,13 @@ export function OrganizerDivisionItem({
description,
maxSpots,
defaultMaxSpots,
teamSize,
index,
registrationCount,
isOnly,
instanceId,
onLabelSave,
onTeamSizeSave,
onDescriptionSave,
onMaxSpotsSave,
onRemove,
Expand All @@ -74,8 +92,31 @@ export function OrganizerDivisionItem({
const labelRef = useRef(label)
const [localDescription, setLocalDescription] = useState(description ?? "")
const [localMaxSpots, setLocalMaxSpots] = useState(maxSpots?.toString() ?? "")
const [localTeamSize, setLocalTeamSize] = useState(teamSize.toString())
const [isExpanded, setIsExpanded] = useState(false)

const effectiveMaxSpots = maxSpots ?? defaultMaxSpots
const isOverCapacity =
effectiveMaxSpots !== null && registrationCount > effectiveMaxSpots
const isAtCapacity =
effectiveMaxSpots !== null && registrationCount === effectiveMaxSpots
const isNearCapacity =
effectiveMaxSpots !== null &&
registrationCount < effectiveMaxSpots &&
registrationCount >= Math.ceil(effectiveMaxSpots * 0.8)
const capacityState = isOverCapacity
? "over"
: isAtCapacity
? "full"
: isNearCapacity
? "near"
: "open"
const capacityLabel =
effectiveMaxSpots === null
? `${registrationCount} / unlimited`
: `${registrationCount} / ${effectiveMaxSpots}`
const teamSizeLabel = teamSize === 1 ? "Individual" : `Team of ${teamSize}`

// Sync local state when prop changes (e.g., after server update)
useEffect(() => {
setLocalLabel(label)
Expand All @@ -90,8 +131,32 @@ export function OrganizerDivisionItem({
setLocalMaxSpots(maxSpots?.toString() ?? "")
}, [maxSpots])

useEffect(() => {
setLocalTeamSize(teamSize.toString())
}, [teamSize])

const canDelete = registrationCount === 0 && !isOnly

const handleMaxSpotsBlur = () => {
const newVal =
localMaxSpots.trim() === "" ? null : parseInt(localMaxSpots, 10)
if (newVal !== maxSpots) {
if (newVal !== null && (Number.isNaN(newVal) || newVal < 1)) {
setLocalMaxSpots(maxSpots?.toString() ?? "")
return
}
onMaxSpotsSave(newVal)
}
}

const handleTeamSizeChange = (value: string) => {
setLocalTeamSize(value)
const nextTeamSize = Number(value)
if (Number.isInteger(nextTeamSize) && nextTeamSize !== teamSize) {
onTeamSizeSave(nextTeamSize)
}
}

useEffect(() => {
const element = ref.current
const dragHandle = dragHandleRef.current
Expand Down Expand Up @@ -201,38 +266,121 @@ export function OrganizerDivisionItem({
{closestEdge && <DropIndicator edge={closestEdge} gap="2px" />}
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div
className={`border rounded-lg bg-background ${isDragging ? "opacity-50" : ""}`}
className={cn(
"border rounded-lg bg-background transition-colors",
isDragging && "opacity-50",
capacityState === "near" && "border-amber-300 bg-amber-50/40",
capacityState === "full" && "border-orange-300 bg-orange-50/40",
capacityState === "over" && "border-destructive bg-destructive/5",
)}
>
<div className="flex items-center gap-2 p-3">
<div className="grid gap-3 p-3 md:grid-cols-[auto_auto_minmax(12rem,1fr)_9rem_12rem_auto_auto] md:items-center">
<button
ref={dragHandleRef}
type="button"
className="cursor-grab active:cursor-grabbing"
className="cursor-grab active:cursor-grabbing justify-self-start"
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<span className="text-sm font-mono text-muted-foreground w-8">
#{index + 1}
</span>
<Input
value={localLabel}
onChange={(e) => setLocalLabel(e.target.value)}
onBlur={() => {
if (localLabel !== label) {
onLabelSave(localLabel)
}
}}
placeholder="Enter division name"
className="flex-1"
/>
<Badge
variant="secondary"
className="hidden sm:flex items-center gap-1"
>
<Users className="h-3 w-3" />
{registrationCount}
</Badge>
<div className="space-y-1">
<label
htmlFor={`divisionName-${id}`}
className="text-xs font-medium text-muted-foreground"
>
Division
</label>
<Input
id={`divisionName-${id}`}
value={localLabel}
onChange={(e) => setLocalLabel(e.target.value)}
onBlur={() => {
if (localLabel !== label) {
onLabelSave(localLabel)
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && localLabel !== label) {
onLabelSave(localLabel)
e.currentTarget.blur()
}
}}
Comment on lines +305 to +310

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Pressing Enter in the division name field triggers onLabelSave twice (keydown + blur), causing duplicate save requests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/wodsmith-start/src/components/divisions/organizer-division-item.tsx, line 307:

<comment>Pressing Enter in the division name field triggers `onLabelSave` twice (keydown + blur), causing duplicate save requests.</comment>

<file context>
@@ -201,38 +268,118 @@ export function OrganizerDivisionItem({
+                    onLabelSave(localLabel)
+                  }
+                }}
+                onKeyDown={(e) => {
+                  if (e.key === "Enter" && localLabel !== label) {
+                    onLabelSave(localLabel)
</file context>
Suggested change
onKeyDown={(e) => {
if (e.key === "Enter" && localLabel !== label) {
onLabelSave(localLabel)
e.currentTarget.blur()
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur()
}
}}

placeholder="Enter division name"
/>
<Badge variant="secondary" className="w-fit">
{teamSizeLabel}
</Badge>
</div>
<div className="space-y-1">
<label
htmlFor={`teamSize-${id}`}
className="text-xs font-medium text-muted-foreground"
>
Type
</label>
<Select
value={localTeamSize}
onValueChange={handleTeamSizeChange}
>
<SelectTrigger id={`teamSize-${id}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 10 }, (_, i) => i + 1).map((size) => (
<SelectItem key={size} value={size.toString()}>
{size === 1 ? "Individual" : `Team of ${size}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label
htmlFor={`maxSpots-${id}`}
className="text-xs font-medium text-muted-foreground"
>
Capacity
</label>
<div className="flex items-center gap-2">
<Input
id={`maxSpots-${id}`}
type="number"
min={1}
value={localMaxSpots}
onChange={(e) => setLocalMaxSpots(e.target.value)}
onBlur={handleMaxSpotsBlur}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleMaxSpotsBlur()
e.currentTarget.blur()
}
}}
placeholder={
defaultMaxSpots ? `${defaultMaxSpots} default` : "Unlimited"
}
className="min-w-0"
/>
<Badge
variant={capacityState === "over" ? "destructive" : "outline"}
className={cn(
"shrink-0 gap-1 whitespace-nowrap",
capacityState === "near" &&
"border-amber-300 text-amber-700",
capacityState === "full" &&
"border-orange-300 text-orange-700",
)}
>
{capacityState !== "open" && (
<AlertTriangle className="h-3 w-3" />
)}
<Users className="h-3 w-3" />
{capacityLabel}
</Badge>
</div>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -274,50 +422,34 @@ export function OrganizerDivisionItem({
</Button>
</CollapsibleTrigger>
</div>
{capacityState !== "open" && (
<div className="px-3 pb-3 md:pl-20">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{capacityState === "near" && (
<span className="font-medium text-amber-700">
Near capacity
</span>
)}
{capacityState === "full" && (
<span className="font-medium text-orange-700">
At capacity
</span>
)}
{capacityState === "over" && (
<span className="font-medium text-destructive">
Over capacity
</span>
)}
</div>
</div>
)}
<CollapsibleContent>
<div className="px-3 pb-3 pl-3 sm:pl-14 space-y-3">
<div className="flex items-center gap-3">
<label
htmlFor={`maxSpots-${id}`}
className="text-sm text-muted-foreground whitespace-nowrap"
>
Max spots:
</label>
<Input
id={`maxSpots-${id}`}
type="number"
min={1}
value={localMaxSpots}
onChange={(e) => setLocalMaxSpots(e.target.value)}
onBlur={() => {
const newVal =
localMaxSpots.trim() === ""
? null
: parseInt(localMaxSpots, 10)
if (newVal !== maxSpots) {
if (
newVal !== null &&
(Number.isNaN(newVal) || newVal < 1)
) {
setLocalMaxSpots(maxSpots?.toString() ?? "")
return
}
onMaxSpotsSave(newVal)
}
}}
placeholder={
defaultMaxSpots
? `${defaultMaxSpots} (default)`
: "Unlimited"
}
className="w-32 text-sm"
/>
<span className="text-xs text-muted-foreground">
{defaultMaxSpots
? "Leave blank to use competition default"
: "Leave blank for unlimited"}
</span>
</div>
<p className="text-xs text-muted-foreground">
{defaultMaxSpots
? "Blank capacity uses the competition default. Add metadata here for gender, category, or eligibility notes."
: "Blank capacity keeps this division unlimited. Add metadata here for gender, category, or eligibility notes."}
</p>
<Textarea
value={localDescription}
onChange={(e) => setLocalDescription(e.target.value)}
Expand Down
Loading
Loading