* Add commish right-click context menus to MiniDraftGrid team headers Adds onAdjustTimeBankOpen and onSetAutodraftOpen props to MiniDraftGrid so the last-two-rounds display on the Participants tab shows the same "Adjust Time Bank…" / "Set Autodraft…" context menu on right-click that the full Draft Board already exposes. Also passes those callbacks from the route's miniDraftGrid useMemo so they are wired up for commissioners. https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1 * Address all code review issues for draft room context menus Layout bug: flex-1 min-w-20 was nested inside headerInner instead of the direct flex-child wrapper in MiniDraftGrid, breaking equal column sizing. Fixed by moving the classes to the outermost element in both the menu and non-menu paths (matching DraftGridSection's pattern). MiniDraftGrid improvements: - Hoist hasHeaderMenu constant above the draftSlots.map() loop - Add optional connectedTeams prop; disconnected teams render italic + muted-foreground, consistent with DraftGridSection - connectedTeams now wired through the route's miniDraftGrid useMemo DraftGridSection interface: - Make onForceAutopick and onForceManualPickOpen optional (?) to match every other commissioner callback; add null checks at all three call sites (context menu, mobile MoreVertical button, mobile Sheet) - Gate those two callbacks behind isCommissioner at the route level Tests (new files): - app/components/__tests__/MiniDraftGrid.test.tsx — covers layout classes, connected/disconnected styling, and all four context menu interactions - app/components/__tests__/DraftGridSection.test.tsx — covers all three context menu surfaces for both commissioner and non-commissioner roles Storybook: add CommissionerView and DisconnectedTeams stories to MiniDraftGrid.stories.tsx https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1 * Move hasHeaderMenu out of IIFE into component body Computing it before the return statement is the right place since it only depends on props, not loop variables. Removes the IIFE entirely and fixes the indentation of the map callback body. https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1 * Mock HTMLElement.scrollTo in test setup jsdom does not implement scrollTo on elements, causing any component that calls element.scrollTo() (e.g. MiniDraftGrid's scroll-to-current-cell effect) to throw in unit tests. https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1 --------- Co-authored-by: Claude <noreply@anthropic.com>
403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
import { memo, useMemo, useState } from "react";
|
|
import { TeamAvatar } from "~/components/TeamAvatar";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
} from "~/components/ui/context-menu";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "~/components/ui/sheet";
|
|
import { Button } from "~/components/ui/button";
|
|
import { MoreVertical } from "lucide-react";
|
|
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
|
import { DraftPickCell } from "~/components/draft/DraftPickCell";
|
|
import type { SeasonStatus } from "~/models/season";
|
|
|
|
type MobileSheetData =
|
|
| { type: "team"; teamId: string }
|
|
| { type: "current-cell"; pickNumber: number; teamId: string }
|
|
| { type: "picked-cell"; pickNumber: number; teamId: string };
|
|
|
|
interface DraftGridSectionProps {
|
|
draftSlots: Array<{
|
|
id: string;
|
|
draftOrder: number;
|
|
team: {
|
|
id: string;
|
|
name: string;
|
|
logoUrl?: string | null;
|
|
};
|
|
}>;
|
|
draftGrid: Array<
|
|
Array<{
|
|
pickNumber: number;
|
|
round: number;
|
|
pickInRound: number;
|
|
teamId: string;
|
|
pick?: {
|
|
participant: {
|
|
name: string;
|
|
};
|
|
sport: {
|
|
name: string;
|
|
};
|
|
};
|
|
}>
|
|
>;
|
|
currentPick: number;
|
|
teamTimers: Record<string, number | undefined>;
|
|
autodraftStatus: Record<string, { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }>;
|
|
connectedTeams: Set<string>;
|
|
isCommissioner: boolean;
|
|
seasonStatus?: SeasonStatus;
|
|
draftPaused?: boolean;
|
|
onAdjustTimeBankOpen?: (teamId: string) => void;
|
|
onSetAutodraftOpen?: (teamId: string) => void;
|
|
onForceAutopick?: (pickNumber: number, teamId: string) => void;
|
|
onForceManualPickOpen?: (pickNumber: number, teamId: string) => void;
|
|
onReplacePick?: (pickNumber: number, teamId: string) => void;
|
|
onRollbackToPick?: (pickNumber: number) => void;
|
|
ownerMap?: Record<string, string>;
|
|
}
|
|
|
|
export const DraftGridSection = memo(function DraftGridSection({
|
|
draftSlots,
|
|
draftGrid,
|
|
currentPick,
|
|
teamTimers,
|
|
autodraftStatus,
|
|
connectedTeams,
|
|
isCommissioner,
|
|
seasonStatus,
|
|
draftPaused,
|
|
onAdjustTimeBankOpen,
|
|
onSetAutodraftOpen,
|
|
onForceAutopick,
|
|
onForceManualPickOpen,
|
|
onReplacePick,
|
|
onRollbackToPick,
|
|
ownerMap = {},
|
|
}: DraftGridSectionProps) {
|
|
const [mobileSheet, setMobileSheet] = useState<MobileSheetData | null>(null);
|
|
|
|
const currentTeamId = useMemo(
|
|
() => draftGrid.flat().find((c) => c.pickNumber === currentPick)?.teamId ?? null,
|
|
[draftGrid, currentPick]
|
|
);
|
|
|
|
return (
|
|
<div className="h-full flex flex-col p-4">
|
|
<h2 className="text-xl font-semibold mb-4 flex-shrink-0">Draft Grid</h2>
|
|
<div className="flex-1 overflow-auto">
|
|
<div className="inline-block min-w-full min-h-full">
|
|
{/* Team Headers */}
|
|
<div className="flex gap-1.5 mb-1.5 sticky top-0 z-10 bg-background/95 backdrop-blur-sm py-1">
|
|
{draftSlots.map((slot) => {
|
|
const teamTime = teamTimers[slot.team.id];
|
|
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false;
|
|
const isConnected = connectedTeams.has(slot.team.id);
|
|
|
|
const headerInner = (
|
|
<div className="relative">
|
|
<div className="flex justify-center mb-1">
|
|
<TeamAvatar
|
|
teamId={slot.team.id}
|
|
teamName={ownerMap[slot.team.id] || slot.team.name}
|
|
logoUrl={slot.team.logoUrl}
|
|
size="md"
|
|
/>
|
|
</div>
|
|
<div
|
|
className={`font-semibold text-xs px-1 flex items-center justify-center ${
|
|
slot.team.id === currentTeamId
|
|
? "text-electric font-bold"
|
|
: !isConnected
|
|
? "italic text-muted-foreground"
|
|
: ""
|
|
}`}
|
|
>
|
|
{isAutodraft && (
|
|
<span className="mr-1 inline-flex items-center justify-center h-4 w-4 text-[10px] font-bold text-white bg-black shrink-0">A</span>
|
|
)}
|
|
<span className="truncate">{ownerMap[slot.team.id] || slot.team.name}</span>
|
|
</div>
|
|
<div
|
|
className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`}
|
|
>
|
|
{formatClockTime(teamTime)}
|
|
</div>
|
|
{isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && (
|
|
<button
|
|
className="absolute top-0 right-0 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
|
|
onClick={() => setMobileSheet({ type: "team", teamId: slot.team.id })}
|
|
>
|
|
<MoreVertical className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen)) {
|
|
return (
|
|
<ContextMenu key={slot.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<div className="flex-1 min-w-20 text-center cursor-context-menu">
|
|
{headerInner}
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
{onAdjustTimeBankOpen && (
|
|
<ContextMenuItem onClick={() => onAdjustTimeBankOpen(slot.team.id)}>
|
|
Adjust Time Bank...
|
|
</ContextMenuItem>
|
|
)}
|
|
{onSetAutodraftOpen && (
|
|
<ContextMenuItem onClick={() => onSetAutodraftOpen(slot.team.id)}>
|
|
Set Autodraft...
|
|
</ContextMenuItem>
|
|
)}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={slot.id} className="flex-1 min-w-20 text-center">
|
|
{headerInner}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Draft Grid Rows */}
|
|
<div className="space-y-1.5">
|
|
{draftGrid.map((roundPicks, roundIndex) => {
|
|
const round = roundIndex + 1;
|
|
const isEvenRound = round % 2 === 0;
|
|
const displayPicks = isEvenRound
|
|
? [...roundPicks].toReversed()
|
|
: roundPicks;
|
|
|
|
return (
|
|
<div key={round} className="flex gap-1.5 items-stretch">
|
|
{displayPicks.map((cell) => {
|
|
const isCurrent = cell.pickNumber === currentPick;
|
|
const isPicked = !!cell.pick;
|
|
const cellState = isPicked ? "picked" : isCurrent ? "current" : "upcoming";
|
|
const pickData = cell.pick
|
|
? { participant: { name: cell.pick.participant.name }, sport: { name: cell.pick.sport.name } }
|
|
: undefined;
|
|
const pendingCorona = cellState === "upcoming" ? { type: "pending" as const } : undefined;
|
|
|
|
const mobileButtons = isCommissioner && (
|
|
<>
|
|
{!isPicked && isCurrent && (onForceAutopick || onForceManualPickOpen) && (
|
|
<button
|
|
className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
|
|
onClick={() => setMobileSheet({ type: "current-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
|
|
>
|
|
<MoreVertical className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
{isPicked && (onReplacePick || onRollbackToPick) && (
|
|
<button
|
|
className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
|
|
onClick={() => setMobileSheet({ type: "picked-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
|
|
>
|
|
<MoreVertical className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
if (isCommissioner && !isPicked && isCurrent && (onForceAutopick || onForceManualPickOpen)) {
|
|
return (
|
|
<ContextMenu key={cell.pickNumber}>
|
|
<ContextMenuTrigger asChild>
|
|
<DraftPickCell
|
|
pickNumber={cell.pickNumber}
|
|
round={cell.round}
|
|
pickInRound={cell.pickInRound}
|
|
state={cellState}
|
|
pick={pickData}
|
|
coronaState={pendingCorona}
|
|
seasonStatus={seasonStatus}
|
|
draftPaused={draftPaused}
|
|
className="cursor-context-menu"
|
|
>
|
|
{mobileButtons}
|
|
</DraftPickCell>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
{onForceAutopick && (
|
|
<ContextMenuItem onClick={() => onForceAutopick(cell.pickNumber, cell.teamId)}>
|
|
Force Auto Pick
|
|
</ContextMenuItem>
|
|
)}
|
|
{onForceManualPickOpen && (
|
|
<ContextMenuItem onClick={() => onForceManualPickOpen(cell.pickNumber, cell.teamId)}>
|
|
Force Manual Pick
|
|
</ContextMenuItem>
|
|
)}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
|
|
return (
|
|
<ContextMenu key={cell.pickNumber}>
|
|
<ContextMenuTrigger asChild>
|
|
<DraftPickCell
|
|
pickNumber={cell.pickNumber}
|
|
round={cell.round}
|
|
pickInRound={cell.pickInRound}
|
|
state={cellState}
|
|
pick={pickData}
|
|
coronaState={pendingCorona}
|
|
seasonStatus={seasonStatus}
|
|
draftPaused={draftPaused}
|
|
className="cursor-context-menu"
|
|
>
|
|
{mobileButtons}
|
|
</DraftPickCell>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
{onReplacePick && (
|
|
<ContextMenuItem
|
|
onClick={() =>
|
|
onReplacePick(cell.pickNumber, cell.teamId)
|
|
}
|
|
>
|
|
Replace Pick
|
|
</ContextMenuItem>
|
|
)}
|
|
{onRollbackToPick && (
|
|
<ContextMenuItem
|
|
onClick={() => onRollbackToPick(cell.pickNumber)}
|
|
className="text-destructive focus:text-destructive"
|
|
>
|
|
Roll Back to This Pick
|
|
</ContextMenuItem>
|
|
)}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DraftPickCell
|
|
key={cell.pickNumber}
|
|
pickNumber={cell.pickNumber}
|
|
round={cell.round}
|
|
pickInRound={cell.pickInRound}
|
|
state={cellState}
|
|
pick={pickData}
|
|
coronaState={pendingCorona}
|
|
seasonStatus={seasonStatus}
|
|
draftPaused={draftPaused}
|
|
>
|
|
{mobileButtons}
|
|
</DraftPickCell>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Commissioner Sheet */}
|
|
<Sheet open={!!mobileSheet} onOpenChange={(open) => !open && setMobileSheet(null)}>
|
|
<SheetContent side="bottom" className="pb-8">
|
|
<SheetHeader>
|
|
<SheetTitle>Commissioner Actions</SheetTitle>
|
|
</SheetHeader>
|
|
<div className="flex flex-col gap-3 p-4">
|
|
{mobileSheet?.type === "team" && (
|
|
<>
|
|
{onAdjustTimeBankOpen && (
|
|
<Button
|
|
onClick={() => {
|
|
onAdjustTimeBankOpen(mobileSheet.teamId);
|
|
setMobileSheet(null);
|
|
}}
|
|
>
|
|
Adjust Time Bank...
|
|
</Button>
|
|
)}
|
|
{onSetAutodraftOpen && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
onSetAutodraftOpen(mobileSheet.teamId);
|
|
setMobileSheet(null);
|
|
}}
|
|
>
|
|
Set Autodraft...
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
{mobileSheet?.type === "current-cell" && (
|
|
<>
|
|
{onForceAutopick && (
|
|
<Button
|
|
onClick={() => {
|
|
onForceAutopick(mobileSheet.pickNumber, mobileSheet.teamId);
|
|
setMobileSheet(null);
|
|
}}
|
|
>
|
|
Force Auto Pick
|
|
</Button>
|
|
)}
|
|
{onForceManualPickOpen && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
onForceManualPickOpen(mobileSheet.pickNumber, mobileSheet.teamId);
|
|
setMobileSheet(null);
|
|
}}
|
|
>
|
|
Force Manual Pick
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
{mobileSheet?.type === "picked-cell" && (
|
|
<>
|
|
{onReplacePick && (
|
|
<Button
|
|
onClick={() => {
|
|
onReplacePick(mobileSheet.pickNumber, mobileSheet.teamId);
|
|
setMobileSheet(null);
|
|
}}
|
|
>
|
|
Replace Pick
|
|
</Button>
|
|
)}
|
|
{onRollbackToPick && (
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => {
|
|
onRollbackToPick(mobileSheet.pickNumber);
|
|
setMobileSheet(null);
|
|
}}
|
|
>
|
|
Roll Back to This Pick
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
);
|
|
});
|