Use sport icons in league views (#394)
This commit is contained in:
parent
20685bae6b
commit
777315541b
5 changed files with 132 additions and 25 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Calendar, CheckCircle2, ChevronRight, Clock, Layers, ListOrdered, SquareStack, Trophy } from "lucide-react";
|
import { Calendar, CheckCircle2, Clock, Layers } from "lucide-react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { SportIcon as SportSeasonIcon } from "~/components/SportIcon";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||||
|
|
@ -21,6 +22,7 @@ export interface DraftedParticipantSummary {
|
||||||
export interface SportSeasonSummaryItem {
|
export interface SportSeasonSummaryItem {
|
||||||
id: string;
|
id: string;
|
||||||
sportName: string;
|
sportName: string;
|
||||||
|
sportIconUrl?: string | null;
|
||||||
seasonName: string;
|
seasonName: string;
|
||||||
status: "upcoming" | "active" | "completed";
|
status: "upcoming" | "active" | "completed";
|
||||||
scoringPattern?: string | null;
|
scoringPattern?: string | null;
|
||||||
|
|
@ -52,25 +54,6 @@ function sortedByStatus(items: SportSeasonSummaryItem[]): SportSeasonSummaryItem
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const PATTERN_ICON: Record<string, { icon: typeof ChevronRight; title: string }> = {
|
|
||||||
playoff_bracket: { icon: ChevronRight, title: "Bracket" },
|
|
||||||
season_standings: { icon: ListOrdered, title: "Season Standings" },
|
|
||||||
qualifying_points: { icon: SquareStack, title: "Qualifying Points" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function SportIcon({ pattern }: { pattern?: string | null }) {
|
|
||||||
const entry = pattern ? PATTERN_ICON[pattern] : null;
|
|
||||||
const Icon = entry?.icon ?? Trophy;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="h-9 w-9 shrink-0 flex items-center justify-center bg-black"
|
|
||||||
title={entry?.title}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format the next upcoming event into a short string. */
|
/** Format the next upcoming event into a short string. */
|
||||||
function formatNextEvent(event: UpcomingParticipantEvent): string {
|
function formatNextEvent(event: UpcomingParticipantEvent): string {
|
||||||
const base = event.matchLabel
|
const base = event.matchLabel
|
||||||
|
|
@ -175,8 +158,15 @@ function SportRow({
|
||||||
: "hover:bg-white/[0.06]"
|
: "hover:bg-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Left: scoring pattern icon in black box */}
|
{/* Left: sport icon */}
|
||||||
<SportIcon pattern={item.scoringPattern} />
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center">
|
||||||
|
<SportSeasonIcon
|
||||||
|
sportName={item.sportName}
|
||||||
|
iconUrl={item.sportIconUrl}
|
||||||
|
size="md"
|
||||||
|
decorative
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Right: name + participants + next event */}
|
{/* Right: name + participants + next event */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { ClipboardList } from "lucide-react";
|
import { ClipboardList } from "lucide-react";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { SportIcon } from "~/components/league/SportIcon";
|
import { SportIcon } from "~/components/league/SportIcon";
|
||||||
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
||||||
|
|
@ -63,13 +62,15 @@ export function SportsSection({
|
||||||
!canEditSports && "cursor-not-allowed opacity-65"
|
!canEditSports && "cursor-not-allowed opacity-65"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<input
|
||||||
id={`sport-${ss.id}`}
|
id={`sport-${ss.id}`}
|
||||||
|
type="checkbox"
|
||||||
name="sportsSeasons"
|
name="sportsSeasons"
|
||||||
value={ss.id}
|
value={ss.id}
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onCheckedChange={() => onSportToggle(ss.id)}
|
onChange={() => onSportToggle(ss.id)}
|
||||||
disabled={!canEditSports}
|
disabled={!canEditSports}
|
||||||
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
<SportIcon sport={ss.sport} />
|
<SportIcon sport={ss.sport} />
|
||||||
<span className="min-w-0 flex-1 overflow-hidden">
|
<span className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
|
|
||||||
114
app/components/league/settings/__tests__/SportsSection.test.tsx
Normal file
114
app/components/league/settings/__tests__/SportsSection.test.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SportsSection } from "../SportsSection";
|
||||||
|
|
||||||
|
type SportsSeason = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
scoringType: string;
|
||||||
|
sport: { id: string; name: string; slug: string; type: string; iconUrl: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const displaySportsSeasons: SportsSeason[] = [
|
||||||
|
{
|
||||||
|
id: "nba-2025",
|
||||||
|
name: "2025 Season",
|
||||||
|
year: 2025,
|
||||||
|
scoringType: "standings",
|
||||||
|
sport: { id: "sport-1", name: "NBA", slug: "nba", type: "team", iconUrl: "/sports-icons/nba.svg" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mlb-2025",
|
||||||
|
name: "2025 Season",
|
||||||
|
year: 2025,
|
||||||
|
scoringType: "playoffs",
|
||||||
|
sport: { id: "sport-2", name: "MLB", slug: "mlb", type: "team", iconUrl: "/sports-icons/mlb.svg" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function SportsSectionHarness({
|
||||||
|
initialSelected = ["nba-2025"],
|
||||||
|
canEditSports = true,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
initialSelected?: string[];
|
||||||
|
canEditSports?: boolean;
|
||||||
|
onSubmit?: (selectedSports: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [selectedSports, setSelectedSports] = useState(new Set(initialSelected));
|
||||||
|
|
||||||
|
const handleSportToggle = (id: string) => {
|
||||||
|
setSelectedSports((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
setSelectedSports(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
onSubmit?.(formData.getAll("sportsSeasons").map(String));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<SportsSection
|
||||||
|
active
|
||||||
|
canEditSports={canEditSports}
|
||||||
|
selectedSports={selectedSports}
|
||||||
|
displaySportsSeasons={displaySportsSeasons}
|
||||||
|
onSportToggle={handleSportToggle}
|
||||||
|
onClearAll={handleClearAll}
|
||||||
|
/>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SportsSection", () => {
|
||||||
|
it("keeps native checkbox semantics while using the row as the visible control", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<SportsSectionHarness />);
|
||||||
|
|
||||||
|
const mlbCheckbox = screen.getByRole("checkbox", { name: /mlb - 2025 season \(2025\)/i });
|
||||||
|
expect(mlbCheckbox).not.toBeChecked();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("MLB - 2025 Season (2025)"));
|
||||||
|
|
||||||
|
expect(mlbCheckbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits the currently selected sports seasons after toggling", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleSubmit = vi.fn();
|
||||||
|
render(<SportsSectionHarness onSubmit={handleSubmit} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText("MLB - 2025 Season (2025)"));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
expect(handleSubmit).toHaveBeenCalledWith(["nba-2025", "mlb-2025"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits an empty sports selection after clearing all", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleSubmit = vi.fn();
|
||||||
|
render(<SportsSectionHarness onSubmit={handleSubmit} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /clear all/i }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
expect(handleSubmit).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -25,6 +25,7 @@ export interface SeasonWithSportsSeasons extends Season {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
iconUrl: string | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
sportsSeasons={sortedSportsSeasons.map((ss) => ({
|
sportsSeasons={sortedSportsSeasons.map((ss) => ({
|
||||||
id: ss.id,
|
id: ss.id,
|
||||||
sportName: ss.sport.name,
|
sportName: ss.sport.name,
|
||||||
|
sportIconUrl: ss.sport.iconUrl,
|
||||||
seasonName: ss.name,
|
seasonName: ss.name,
|
||||||
status: ss.status,
|
status: ss.status,
|
||||||
scoringPattern: ss.scoringPattern,
|
scoringPattern: ss.scoringPattern,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue