diff --git a/app/components/__tests__/AvailableParticipantsSection.test.tsx b/app/components/__tests__/AvailableParticipantsSection.test.tsx new file mode 100644 index 0000000..a4dd801 --- /dev/null +++ b/app/components/__tests__/AvailableParticipantsSection.test.tsx @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection"; + +const defaultProps = { + participants: [ + { id: "1", name: "Player A", sport: { id: "s1", name: "NFL" } }, + { id: "2", name: "Player B", sport: { id: "s2", name: "NBA" } }, + { id: "3", name: "Player C", sport: { id: "s1", name: "NFL" } }, + ], + searchQuery: "", + sportFilters: [] as string[], + hideDrafted: false, + hideIneligible: false, + hideCompletedSports: false, + uniqueSports: ["NBA", "NFL"], + draftedParticipantIds: new Set(), + queue: [] as Array<{ id: string; participantId: string }>, + eligibility: null, + canPick: false, + hasTeam: false, + onSearchChange: vi.fn(), + onSportFiltersChange: vi.fn(), + onHideDraftedChange: vi.fn(), + onHideIneligibleChange: vi.fn(), + onHideCompletedSportsChange: vi.fn(), + onMakePick: vi.fn(), + onAddToQueue: vi.fn(), + onRemoveFromQueue: vi.fn(), +}; + +// Both the mobile Sheet trigger and desktop Popover trigger are rendered in jsdom +// (CSS breakpoints are not applied). Click the first one to open the Sheet. +let user: ReturnType; + +function clickFirstSportFilterTrigger(name: string | RegExp) { + return user.click(screen.getAllByRole("button", { name })[0]); +} + +describe("AvailableParticipantsSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + user = userEvent.setup(); + }); + + describe("Rendering", () => { + it("renders all participants by name", () => { + render(); + expect(screen.getAllByText("Player A").length).toBeGreaterThan(0); + expect(screen.getAllByText("Player B").length).toBeGreaterThan(0); + expect(screen.getAllByText("Player C").length).toBeGreaterThan(0); + }); + + it("renders search input", () => { + render(); + expect( + screen.getByPlaceholderText("Search participants...") + ).toBeInTheDocument(); + }); + + it("renders the Show Drafted toggle", () => { + render(); + expect(screen.getByText("Show Drafted")).toBeInTheDocument(); + }); + + it("renders Show Ineligible and Show drafted sports toggles when hasTeam=true", () => { + render( + + ); + expect(screen.getByText("Show Ineligible")).toBeInTheDocument(); + expect(screen.getByText("Show drafted sports")).toBeInTheDocument(); + }); + + it("does not render Show Ineligible when hasTeam=false", () => { + render( + + ); + expect(screen.queryByText("Show Ineligible")).not.toBeInTheDocument(); + }); + + it("renders empty state when no participants", () => { + render( + + ); + expect(screen.getAllByText("No participants found.").length).toBeGreaterThan(0); + }); + }); + + describe("Sport Filter - trigger label", () => { + it('shows "All Sports" in aria-label when no filters selected', () => { + render(); + // Both mobile and desktop triggers share the same label + const triggers = screen.getAllByRole("button", { + name: "Filter by sport: All Sports", + }); + expect(triggers.length).toBeGreaterThanOrEqual(1); + }); + + it("shows sport name in aria-label when exactly one filter selected", () => { + render( + + ); + const triggers = screen.getAllByRole("button", { + name: "Filter by sport: NFL", + }); + expect(triggers.length).toBeGreaterThanOrEqual(1); + }); + + it("shows count in aria-label when multiple filters selected", () => { + render( + + ); + const triggers = screen.getAllByRole("button", { + name: "Filter by sport: 2 sports selected", + }); + expect(triggers.length).toBeGreaterThanOrEqual(1); + }); + + it("does not render active-filter badges", () => { + render( + + ); + expect(screen.queryByLabelText(/Remove .* filter/)).not.toBeInTheDocument(); + }); + }); + + describe("Sport Filter - sheet/popover", () => { + it("opens and shows checkboxes for each sport", async () => { + render(); + await clickFirstSportFilterTrigger("Filter by sport: All Sports"); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByText("NBA")).toBeInTheDocument(); + expect(within(dialog).getByText("NFL")).toBeInTheDocument(); + }); + + it("calls onSportFiltersChange with sport added when checkbox clicked", async () => { + render(); + await clickFirstSportFilterTrigger("Filter by sport: All Sports"); + const dialog = screen.getByRole("dialog"); + await user.click(within(dialog).getByLabelText("NFL")); + expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith(["NFL"]); + }); + + it("calls onSportFiltersChange with sport removed when unchecked", async () => { + render( + + ); + await clickFirstSportFilterTrigger("Filter by sport: 2 sports selected"); + const dialog = screen.getByRole("dialog"); + await user.click(within(dialog).getByLabelText("NFL")); + expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith(["NBA"]); + }); + + it('does not show "Reset" when no filters active', async () => { + render(); + await clickFirstSportFilterTrigger("Filter by sport: All Sports"); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).queryByText("Reset")).not.toBeInTheDocument(); + }); + + it('shows "Reset" button when filters exist', async () => { + render( + + ); + await clickFirstSportFilterTrigger("Filter by sport: NFL"); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByText("Reset")).toBeInTheDocument(); + }); + + it('calls onSportFiltersChange([]) when "Reset" clicked', async () => { + render( + + ); + await clickFirstSportFilterTrigger("Filter by sport: NFL"); + const dialog = screen.getByRole("dialog"); + await user.click(within(dialog).getByText("Reset")); + expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith([]); + }); + + it("Done button closes the sheet", async () => { + render(); + await clickFirstSportFilterTrigger("Filter by sport: All Sports"); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Done" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx index e105cce..2fed65b 100644 --- a/app/components/draft/AvailableParticipantsSection.tsx +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -1,7 +1,21 @@ import { useMemo } from "react"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; -import { ListPlus, ListX } from "lucide-react"; +import { Checkbox } from "~/components/ui/checkbox"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "~/components/ui/popover"; +import { + Sheet, + SheetTrigger, + SheetContent, + SheetTitle, + SheetFooter, + SheetClose, +} from "~/components/ui/sheet"; +import { ListPlus, ListX, ChevronDown } from "lucide-react"; function getParticipantState( participant: { id: string; sport: { id: string } }, @@ -31,7 +45,7 @@ interface AvailableParticipantsSectionProps { }; }>; searchQuery: string; - sportFilter: string; + sportFilters: string[]; hideDrafted: boolean; hideIneligible: boolean; hideCompletedSports: boolean; @@ -45,7 +59,7 @@ interface AvailableParticipantsSectionProps { canPick: boolean; hasTeam: boolean; onSearchChange: (query: string) => void; - onSportFilterChange: (sport: string) => void; + onSportFiltersChange: (sports: string[]) => void; onHideDraftedChange: (hide: boolean) => void; onHideIneligibleChange: (hide: boolean) => void; onHideCompletedSportsChange: (hide: boolean) => void; @@ -57,7 +71,7 @@ interface AvailableParticipantsSectionProps { export function AvailableParticipantsSection({ participants, searchQuery, - sportFilter, + sportFilters, hideDrafted, hideIneligible, hideCompletedSports, @@ -68,7 +82,7 @@ export function AvailableParticipantsSection({ canPick, hasTeam, onSearchChange, - onSportFilterChange, + onSportFiltersChange, onHideDraftedChange, onHideIneligibleChange, onHideCompletedSportsChange, @@ -81,15 +95,40 @@ export function AvailableParticipantsSection({ [queue] ); + const sportFilterSet = useMemo(() => new Set(sportFilters), [sportFilters]); + + const triggerText = + sportFilters.length === 0 + ? "All Sports" + : sportFilters.length === 1 + ? sportFilters[0] + : `${sportFilters.length} sports`; + + const triggerAriaLabel = + sportFilters.length === 0 + ? "Filter by sport: All Sports" + : sportFilters.length === 1 + ? `Filter by sport: ${sportFilters[0]}` + : `Filter by sport: ${sportFilters.length} sports selected`; + + function handleToggleSport(sport: string, checked: boolean | "indeterminate") { + if (checked === true) { + onSportFiltersChange([...sportFilters, sport]); + } else { + onSportFiltersChange(sportFilters.filter((s) => s !== sport)); + } + } + const emptyMessage = useMemo(() => { if (participants.length > 0) return null; const active: string[] = []; if (hideDrafted) active.push("drafted players"); if (hideIneligible && eligibility) active.push("ineligible players"); if (hideCompletedSports) active.push("drafted sports"); + if (sportFilters.length > 0) active.push("other sports"); if (active.length === 0) return "No participants found."; return `No participants found. Try showing ${active.join(", ")}.`; - }, [participants.length, hideDrafted, hideIneligible, hideCompletedSports, eligibility]); + }, [participants.length, hideDrafted, hideIneligible, hideCompletedSports, eligibility, sportFilters]); return (
@@ -105,19 +144,109 @@ export function AvailableParticipantsSection({ />
- {/* Sport Filter Dropdown */} - + {/* Sport Filter Multi-Select */} +
+ {/* Mobile: bottom sheet */} +
+ + + + + + Filter by sport +
+ {uniqueSports.map((sport) => { + const checkboxId = `sheet-sport-filter-${sport.replace(/\s+/g, "-").toLowerCase()}`; + return ( + + ); + })} +
+ + {sportFilters.length > 0 && ( + + )} + + + + +
+
+
+ + {/* Desktop: popover */} +
+ + + + + + {sportFilters.length > 0 && ( +
+ +
+ )} +
+ {uniqueSports.map((sport) => { + const checkboxId = `popover-sport-filter-${sport.replace(/\s+/g, "-").toLowerCase()}`; + return ( + + ); + })} +
+
+
+
+
{/* Show/Hide Drafted Toggle */} )}
+
diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 90767c5..158e07c 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -434,7 +434,7 @@ export default function DraftRoom() { const [hideDrafted, setHideDrafted] = useState(true); const [hideIneligible, setHideIneligible] = useState(true); const [hideCompletedSports, setHideCompletedSports] = useState(false); - const [sportFilter, setSportFilter] = useState("all"); + const [sportFilters, setSportFilters] = useState([]); const [queue, setQueue] = useState(userQueue); const [isPaused, setIsPaused] = useState(season.draftPaused || false); const [isDraftComplete, setIsDraftComplete] = useState( @@ -1275,45 +1275,44 @@ export default function DraftRoom() { ); // Filter participants based on search, sport, drafted status, and eligibility - const filteredParticipants = useMemo( - () => - availableParticipants.filter((participant: any) => { - // Drafted filter - hide drafted participants by default - if (hideDrafted && draftedParticipantIds.has(participant.id)) { + const filteredParticipants = useMemo(() => { + const sportFilterSet = new Set(sportFilters); + return availableParticipants.filter((participant: any) => { + // Drafted filter - hide drafted participants by default + if (hideDrafted && draftedParticipantIds.has(participant.id)) { + return false; + } + // Eligibility filter - hide ineligible participants by default (if user has a team) + if (hideIneligible && eligibility) { + const isEligible = eligibility.eligibleSportIds.has(participant.sport.id); + if (!isEligible) { return false; } - // Eligibility filter - hide ineligible participants by default (if user has a team) - if (hideIneligible && eligibility) { - const isEligible = eligibility.eligibleSportIds.has(participant.sport.id); - if (!isEligible) { - return false; - } - } - // Hide completed sports filter - hide participants from sports already drafted by user's team - if (hideCompletedSports && userDraftedSportIds.has(participant.sport.id)) { - return false; - } - // Search filter - if ( - searchQuery && - !participant.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) { - return false; - } - // Sport filter - if (sportFilter !== "all" && participant.sport.name !== sportFilter) { - return false; - } - return true; - }), - [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilter] - ); + } + // Hide completed sports filter - hide participants from sports already drafted by user's team + if (hideCompletedSports && userDraftedSportIds.has(participant.sport.id)) { + return false; + } + // Search filter + if ( + searchQuery && + !participant.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) { + return false; + } + // Sport filter + if (sportFilterSet.size > 0 && !sportFilterSet.has(participant.sport.name)) { + return false; + } + return true; + }); + }, [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters]); // Shared component props — defined once to avoid duplication between desktop and mobile layouts const availableParticipantsSectionProps = { participants: filteredParticipants, searchQuery, - sportFilter, + sportFilters, hideDrafted, hideIneligible, uniqueSports, @@ -1323,7 +1322,7 @@ export default function DraftRoom() { canPick, hasTeam: !!userTeam, onSearchChange: setSearchQuery, - onSportFilterChange: setSportFilter, + onSportFiltersChange: setSportFilters, onHideDraftedChange: setHideDrafted, onHideIneligibleChange: setHideIneligible, hideCompletedSports,