From 8aabb180b370531d18bd96090b9df9d3c52510a8 Mon Sep 17 00:00:00 2001 From: Lowell Date: Thu, 1 Feb 2024 21:18:03 -0500 Subject: [PATCH] awesome scrim page --- frontend2/src/api/compete/competeApi.ts | 13 + frontend2/src/api/compete/competeKeys.ts | 3 + frontend2/src/api/compete/useCompete.ts | 44 ++- frontend2/src/components/Modal.tsx | 2 +- .../components/compete/MatchRatingDelta.tsx | 41 +++ .../src/components/compete/RatingDelta.tsx | 45 --- .../src/components/compete/TeamWithRating.tsx | 62 ++++ .../elements/DescriptiveCheckbox.tsx | 8 +- frontend2/src/components/elements/Pill.tsx | 19 +- .../src/components/elements/SelectMenu.tsx | 24 +- .../elements/SelectMultipleMenu.tsx | 118 ++++++++ .../sidebar/__test__/sidebar.test.tsx | 2 +- .../src/components/tables/QueueTable.tsx | 2 +- .../src/components/tables/RankingsTable.tsx | 8 +- .../tables/TournamentResultsTable.tsx | 2 +- .../tables/scrimmaging/InboxTable.tsx | 138 +++++++-- .../tables/scrimmaging/OutboxTable.tsx | 124 ++++++-- .../tables/scrimmaging/RequestScrimModal.tsx | 158 +++++++++++ .../tables/scrimmaging/ScrimHistoryTable.tsx | 194 +++++++------ .../tables/scrimmaging/TeamsTable.tsx | 267 ++++++++++++------ .../scrimmaging/TournamentMatchesTable.tsx | 195 +++++++------ frontend2/src/utils/utilTypes.ts | 20 ++ frontend2/src/views/MyTeam.tsx | 22 +- frontend2/src/views/Scrimmaging.tsx | 242 +++++++--------- frontend2/tsconfig.json | 1 + 25 files changed, 1219 insertions(+), 535 deletions(-) create mode 100644 frontend2/src/components/compete/MatchRatingDelta.tsx delete mode 100644 frontend2/src/components/compete/RatingDelta.tsx create mode 100644 frontend2/src/components/compete/TeamWithRating.tsx create mode 100644 frontend2/src/components/elements/SelectMultipleMenu.tsx create mode 100644 frontend2/src/components/tables/scrimmaging/RequestScrimModal.tsx diff --git a/frontend2/src/api/compete/competeApi.ts b/frontend2/src/api/compete/competeApi.ts index 9a07b300a..a40fbf903 100644 --- a/frontend2/src/api/compete/competeApi.ts +++ b/frontend2/src/api/compete/competeApi.ts @@ -18,6 +18,7 @@ import { type CompeteMatchTournamentListRequest, type CompeteMatchListRequest, type CompeteSubmissionTournamentListRequest, + type CompeteRequestDestroyRequest, } from "../_autogen"; import { DEFAULT_API_CONFIGURATION, downloadFile } from "../helpers"; @@ -111,6 +112,18 @@ export const rejectScrimmage = async ({ await API.competeRequestRejectCreate({ episodeId, id }); }; +/** + * Cancel a scrimmage request. + * @param episodeId The current episode's ID. + * @param id The scrimmage's ID to cancel. + */ +export const cancelScrimmage = async ({ + episodeId, + id, +}: CompeteRequestDestroyRequest): Promise => { + await API.competeRequestDestroy({ episodeId, id }); +}; + /** * Get a paginated list of the currently logged in user's incoming scrimmage requests. * @param episodeId The current episode's ID. diff --git a/frontend2/src/api/compete/competeKeys.ts b/frontend2/src/api/compete/competeKeys.ts index 3c00fcd0f..107ec880e 100644 --- a/frontend2/src/api/compete/competeKeys.ts +++ b/frontend2/src/api/compete/competeKeys.ts @@ -80,4 +80,7 @@ export const competeMutationKeys = { rejectScrim: ({ episodeId }: { episodeId: string }) => ["compete", episodeId, "scrimmage", "reject"] as const, + + cancelScrim: ({ episodeId }: { episodeId: string }) => + ["compete", episodeId, "scrimmage", "cancel"] as const, }; diff --git a/frontend2/src/api/compete/useCompete.ts b/frontend2/src/api/compete/useCompete.ts index 86ef3982d..170a9367a 100644 --- a/frontend2/src/api/compete/useCompete.ts +++ b/frontend2/src/api/compete/useCompete.ts @@ -14,6 +14,7 @@ import type { CompeteMatchTournamentListRequest, CompeteRequestAcceptCreateRequest, CompeteRequestCreateRequest, + CompeteRequestDestroyRequest, CompeteRequestInboxListRequest, CompeteRequestOutboxListRequest, CompeteRequestRejectCreateRequest, @@ -29,6 +30,7 @@ import type { } from "../_autogen"; import { acceptScrimmage, + cancelScrimmage, getAllUserTournamentSubmissions, getMatchesList, getScrimmagesListByTeam, @@ -403,7 +405,6 @@ export const useRequestScrimmage = ( }); // Invalidate the outbox query - // TODO: ensure correct invalidation behavior! queryClient .invalidateQueries({ queryKey: competeQueryKeys.outbox({ episodeId }), @@ -425,7 +426,7 @@ export const useRequestScrimmage = ( return await toast.promise(toastFn(), { loading: "Requesting scrimmage...", success: "Scrimmage requested!", - error: "Error requesting scrimmage.", + error: "Error requesting scrimmage. Is the requested team eligible?", }); }, }); @@ -497,7 +498,6 @@ export const useRejectScrimmage = ( await rejectScrimmage({ episodeId, id }); // Invalidate the inbox query - // TODO: ensure correct invalidation behavior! queryClient .invalidateQueries({ queryKey: competeQueryKeys.inbox({ episodeId }), @@ -521,3 +521,41 @@ export const useRejectScrimmage = ( }); }, }); + +/** + * For cancelling a scrimmage request. + */ +export const useCancelScrimmage = ( + { episodeId }: { episodeId: string }, + queryClient: QueryClient, +): UseMutationResult => + useMutation({ + mutationKey: competeMutationKeys.cancelScrim({ episodeId }), + mutationFn: async ({ episodeId, id }: CompeteRequestDestroyRequest) => { + const toastFn = async (): Promise => { + await cancelScrimmage({ episodeId, id }); + + // Invalidate the outbox query + queryClient + .invalidateQueries({ + queryKey: competeQueryKeys.outbox({ episodeId }), + }) + .catch((e) => toast.error((e as Error).message)); + + // Prefetch the first page of the outbox list + queryClient + .prefetchQuery({ + queryKey: competeQueryKeys.outbox({ episodeId, page: 1 }), + queryFn: async () => + await getUserScrimmagesOutboxList({ episodeId, page: 1 }), + }) + .catch((e) => toast.error((e as Error).message)); + }; + + await toast.promise(toastFn(), { + loading: "Cancelling scrimmage...", + success: "Scrimmage cancelled!", + error: "Error cancelling scrimmage.", + }); + }, + }); diff --git a/frontend2/src/components/Modal.tsx b/frontend2/src/components/Modal.tsx index 80cc755ec..a405eb583 100644 --- a/frontend2/src/components/Modal.tsx +++ b/frontend2/src/components/Modal.tsx @@ -40,7 +40,7 @@ const Modal: React.FC = ({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + = ({ + includeTeamName, + participant, + ranked, +}) => { + let newRating = 0; + if (ranked) { + newRating = + participant.rating !== null + ? Math.round(participant.rating) + : Math.round(participant.old_rating); + } else { + newRating = Math.round(participant.old_rating); + } + const oldRating = + participant.old_rating !== null ? Math.round(participant.old_rating) : 0; + const ratingDelta = Math.abs(newRating - oldRating); + + const includeName = includeTeamName === undefined || includeTeamName; + return ( + + ); +}; + +export default MatchRatingDelta; diff --git a/frontend2/src/components/compete/RatingDelta.tsx b/frontend2/src/components/compete/RatingDelta.tsx deleted file mode 100644 index 784f2b124..000000000 --- a/frontend2/src/components/compete/RatingDelta.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { NavLink } from "react-router-dom"; -import { type MatchParticipant } from "../../api/_autogen"; -import React from "react"; -import { useEpisodeId } from "../../contexts/EpisodeContext"; - -interface RatingDeltaProps { - participant: MatchParticipant; - ranked: boolean; -} - -const RatingDelta: React.FC = ({ participant, ranked }) => { - const { episodeId } = useEpisodeId(); - - const newRating = ranked - ? Math.round(participant.rating) - : Math.round(participant.old_rating); - const oldRating = - participant.old_rating !== null ? Math.round(participant.old_rating) : 0; - const ratingDelta = Math.abs(newRating - oldRating); - const deltaClass = - newRating > oldRating - ? "text-xs font-semibold slashed-zero text-green-700" - : newRating < oldRating - ? "text-xs font-semibold slashed-zero text-red-700" - : "text-xs font-semibold slashed-zero text-gray-700"; - return ( - <> - - {participant.teamname} - - {` (${newRating} `} - - {`${ - newRating > oldRating ? " +" : newRating < oldRating ? " -" : " ±" - }${ratingDelta}`} - - {`)`} - - ); -}; - -export default RatingDelta; diff --git a/frontend2/src/components/compete/TeamWithRating.tsx b/frontend2/src/components/compete/TeamWithRating.tsx new file mode 100644 index 000000000..2facbd92e --- /dev/null +++ b/frontend2/src/components/compete/TeamWithRating.tsx @@ -0,0 +1,62 @@ +import React, { useMemo } from "react"; +import { useEpisodeId } from "../../contexts/EpisodeContext"; +import { NavLink } from "react-router-dom"; + +interface TeamWithRatingProps { + teamName: string; + teamId: number; + includeTeamName: boolean; + rating: number; + ratingDelta?: number; +} + +const TeamWithRating: React.FC = ({ + teamName, + teamId, + includeTeamName, + rating, + ratingDelta, +}) => { + const { episodeId } = useEpisodeId(); + + const ratingComponent = useMemo(() => { + if (ratingDelta !== undefined) { + const deltaClass = + ratingDelta > 0 + ? "text-xs font-semibold slashed-zero text-green-700" + : rating < ratingDelta + ? "text-xs font-semibold slashed-zero text-red-700" + : "text-xs font-semibold slashed-zero text-gray-700"; + return ( + + {" "} + {includeTeamName && {"("}} + {`${ + ratingDelta > 0 ? " +" : ratingDelta < 0 ? " -" : " ±" + }${ratingDelta.toFixed(0)}`} + {includeTeamName && {")"}} + + ); + } else { + return ( + + {" "} + {includeTeamName && {"("}} + {rating.toFixed(0)} + {includeTeamName && {")"}} + + ); + } + }, [rating, ratingDelta, includeTeamName]); + + return ( + <> + + {includeTeamName && {teamName}} + {ratingComponent} + + + ); +}; + +export default TeamWithRating; diff --git a/frontend2/src/components/elements/DescriptiveCheckbox.tsx b/frontend2/src/components/elements/DescriptiveCheckbox.tsx index be3e70ff9..2e6f15216 100644 --- a/frontend2/src/components/elements/DescriptiveCheckbox.tsx +++ b/frontend2/src/components/elements/DescriptiveCheckbox.tsx @@ -25,6 +25,7 @@ export const getCheckboxState = ( }; interface DescriptiveCheckboxProps { + disabled?: boolean; status: CheckboxState; onChange: (checked: boolean) => void; title: string; @@ -32,6 +33,7 @@ interface DescriptiveCheckboxProps { } const DescriptiveCheckbox: React.FC = ({ + disabled = false, status, onChange, title, @@ -41,13 +43,13 @@ const DescriptiveCheckbox: React.FC = ({
-
{title}
+
{title}
{description}
diff --git a/frontend2/src/components/elements/Pill.tsx b/frontend2/src/components/elements/Pill.tsx index b0aaf8f6f..3b372e4cd 100644 --- a/frontend2/src/components/elements/Pill.tsx +++ b/frontend2/src/components/elements/Pill.tsx @@ -4,7 +4,7 @@ import Icon from "./Icon"; interface PillProps { text: string; deletable?: boolean; - onDelete?: () => void; + onDelete?: (ev?: React.MouseEvent) => void; className?: string; } @@ -19,12 +19,17 @@ const Pill: React.FC = ({ className={`gap flex max-w-max flex-row items-center justify-center gap-x-1 rounded-full bg-cyan-50 py-1 pl-3 pr-2 text-sm text-cyan-700 ring-1 ring-inset ring-cyan-600/20 ${className}`} > {text} - + {deletable && ( + + )}
); }; diff --git a/frontend2/src/components/elements/SelectMenu.tsx b/frontend2/src/components/elements/SelectMenu.tsx index 4bda266bb..2a6aa303c 100644 --- a/frontend2/src/components/elements/SelectMenu.tsx +++ b/frontend2/src/components/elements/SelectMenu.tsx @@ -6,6 +6,7 @@ import FormLabel from "./FormLabel"; interface SelectMenuProps { options: Array<{ value: T; label: string }>; + disabled?: boolean; label?: string; required?: boolean; value?: T; @@ -15,11 +16,17 @@ interface SelectMenuProps { onChange?: (value: T) => void; } +const DISABLED = "bg-gray-200 ring-gray-200 text-gray-500 cursor-not-allowed"; +const INVALID = "ring-red-500 text-gray-900"; +const DEFAULT = "ring-gray-600 ring-cyan-600 text-gray-900"; +const PLACEHOLDER = "ring-gray-400 text-gray-400"; + function SelectMenu({ label, required = false, options, value, + disabled = false, placeholder, className = "", errorMessage, @@ -30,9 +37,15 @@ function SelectMenu({ [options], ); const invalid = errorMessage !== undefined; + + let stateStyle = DEFAULT; + if (disabled) stateStyle = DISABLED; + else if (invalid) stateStyle = INVALID; + else if (value === undefined) stateStyle = PLACEHOLDER; + return (
- +
{label !== undefined && ( @@ -40,12 +53,11 @@ function SelectMenu({ )} - + {value === undefined ? placeholder : valueToLabel.get(value)}
{ + options: Array<{ value: T; label: string }>; + disabled?: boolean; + label?: string; + required?: boolean; + value?: T[]; + placeholder?: string; + className?: string; + errorMessage?: string; + onChange?: (value: T[]) => void; +} + +const DISABLED = "bg-gray-200 ring-gray-200 text-gray-500 cursor-not-allowed"; +const INVALID = "ring-red-500 text-gray-900"; +const DEFAULT = "ring-gray-600 ring-cyan-600 text-gray-900"; +const PLACEHOLDER = "ring-gray-400 text-gray-400"; + +function SelectMultipleMenu({ + label, + required = false, + options, + disabled = false, + value, + placeholder, + className = "", + errorMessage, + onChange, +}: SelectMultipleMenuProps): JSX.Element { + const valueToLabel = useMemo( + () => new Map(options.map((option) => [option.value, option.label])), + [options], + ); + const removeOption = (option: T): void => { + if (value === undefined || onChange === undefined) return; + onChange(value.filter((v) => v !== option)); + }; + const invalid = errorMessage !== undefined; + + let stateStyle = DEFAULT; + if (disabled) stateStyle = DISABLED; + else if (invalid) stateStyle = INVALID; + else if (value === undefined || value.length === 0) stateStyle = PLACEHOLDER; + + return ( +
+ +
+ {label !== undefined && ( + + + + )} + +
+ {value === undefined || value.length === 0 + ? placeholder + : value.map((v) => ( + { + removeOption(v); + }} + /> + ))} +
+
+ +
+
+ + + {options.map((option) => ( + +
{option.label}
+ + + +
+ ))} +
+
+
+
+ {invalid && } +
+ ); +} + +export default SelectMultipleMenu; diff --git a/frontend2/src/components/sidebar/__test__/sidebar.test.tsx b/frontend2/src/components/sidebar/__test__/sidebar.test.tsx index fa7776444..0af5d49c3 100644 --- a/frontend2/src/components/sidebar/__test__/sidebar.test.tsx +++ b/frontend2/src/components/sidebar/__test__/sidebar.test.tsx @@ -1,5 +1,5 @@ export {}; // Trivial test case to make CI work :) -test("Trivial test!", () => { +test("Trivial test case", () => { expect(1).toEqual(1); }); diff --git a/frontend2/src/components/tables/QueueTable.tsx b/frontend2/src/components/tables/QueueTable.tsx index 5e6ce548a..a113ab589 100644 --- a/frontend2/src/components/tables/QueueTable.tsx +++ b/frontend2/src/components/tables/QueueTable.tsx @@ -6,7 +6,7 @@ import Table from "../Table"; import TableBottom from "../TableBottom"; import MatchScore from "../compete/MatchScore"; import MatchStatus from "../compete/MatchStatus"; -import RatingDelta from "../compete/RatingDelta"; +import RatingDelta from "../compete/MatchRatingDelta"; interface QueueTableProps { data: Maybe; diff --git a/frontend2/src/components/tables/RankingsTable.tsx b/frontend2/src/components/tables/RankingsTable.tsx index 234c8703b..1c61052e2 100644 --- a/frontend2/src/components/tables/RankingsTable.tsx +++ b/frontend2/src/components/tables/RankingsTable.tsx @@ -9,6 +9,7 @@ import TableBottom from "../TableBottom"; import { NavLink } from "react-router-dom"; import EligibilityIcon from "../EligibilityIcon"; import { isPresent } from "../../utils/utilTypes"; +import { useEpisodeId } from "../../contexts/EpisodeContext"; interface RankingsTableProps { data: Maybe; @@ -32,6 +33,8 @@ const RankingsTable: React.FC = ({ eligibilityMap, handlePage, }) => { + const { episodeId } = useEpisodeId(); + return ( = ({ header: "Team", key: "team", value: (team) => ( - + {team.name} ), diff --git a/frontend2/src/components/tables/TournamentResultsTable.tsx b/frontend2/src/components/tables/TournamentResultsTable.tsx index d9430c3e5..3bf43822a 100644 --- a/frontend2/src/components/tables/TournamentResultsTable.tsx +++ b/frontend2/src/components/tables/TournamentResultsTable.tsx @@ -11,7 +11,7 @@ import Table from "../Table"; import TableBottom from "../TableBottom"; import MatchScore from "../compete/MatchScore"; import MatchStatus from "../compete/MatchStatus"; -import RatingDelta from "../compete/RatingDelta"; +import RatingDelta from "../compete/MatchRatingDelta"; import { isNil } from "lodash"; interface TournamentResultsTableProps { diff --git a/frontend2/src/components/tables/scrimmaging/InboxTable.tsx b/frontend2/src/components/tables/scrimmaging/InboxTable.tsx index 59b474028..831912fd3 100644 --- a/frontend2/src/components/tables/scrimmaging/InboxTable.tsx +++ b/frontend2/src/components/tables/scrimmaging/InboxTable.tsx @@ -1,37 +1,117 @@ -import React from "react"; -import type { PaginatedScrimmageRequestList } from "../../../api/_autogen"; -import type { Maybe } from "../../../utils/utilTypes"; -import Table from "../../Table"; +import React, { Fragment } from "react"; +import Table from "components/Table"; +import { + useAcceptScrimmage, + useRejectScrimmage, + useScrimmageInboxList, +} from "api/compete/useCompete"; +import { useEpisodeId } from "contexts/EpisodeContext"; +import { useQueryClient } from "@tanstack/react-query"; +import TableBottom from "components/TableBottom"; +import Button from "components/elements/Button"; +import { dateTime } from "utils/dateTime"; +import TeamWithRating from "components/compete/TeamWithRating"; +import { stringifyPlayerOrder } from "utils/utilTypes"; +import Pill from "components/elements/Pill"; interface InboxTableProps { - data: Maybe; - loading: boolean; + inboxPage: number; + handlePage: (page: number, key: "inboxPage") => void; } -const InboxTable: React.FC = ({ data, loading }) => { +const InboxTable: React.FC = ({ inboxPage, handlePage }) => { + const { episodeId } = useEpisodeId(); + const queryClient = useQueryClient(); + const inboxData = useScrimmageInboxList( + { episodeId, page: inboxPage }, + queryClient, + ); + + const accept = useAcceptScrimmage({ episodeId }, queryClient); + const reject = useRejectScrimmage({ episodeId }, queryClient); + return ( -
req.id.toString()} - columns={[ - { - header: "Team", - key: "requestor", - value: (req) => req.requested_by_name, - }, - { - header: "", - key: "accept", - value: (req) => "ACCEPT", - }, - { - header: "", - key: "reject", - value: (req) => "REJECT", - }, - ]} - /> + +

+ Incoming Scrimmage Requests +

+
req.id.toString()} + bottomElement={ + { + handlePage(page, "inboxPage"); + }} + /> + } + columns={[ + { + header: "Team", + key: "requestor", + value: (req) => ( + + ), + }, + { + header: "Player Order", + key: "player_order", + value: (req) => stringifyPlayerOrder(req.player_order), + }, + { + header: "Maps", + key: "maps", + value: (req) => ( +
+ {req.maps.map((mapItem) => ( + + ))} +
+ ), + }, + { + header: "Requested At", + key: "requested_at", + value: (req) => dateTime(req.created).localFullString, + }, + { + header: "", + key: "accept_reject", + value: (req) => { + return ( +
+
+ ); + }, + }, + ]} + /> + ); }; diff --git a/frontend2/src/components/tables/scrimmaging/OutboxTable.tsx b/frontend2/src/components/tables/scrimmaging/OutboxTable.tsx index c4da3ae3f..d9e7180a6 100644 --- a/frontend2/src/components/tables/scrimmaging/OutboxTable.tsx +++ b/frontend2/src/components/tables/scrimmaging/OutboxTable.tsx @@ -1,32 +1,108 @@ -import React from "react"; -import type { PaginatedScrimmageRequestList } from "../../../api/_autogen"; -import type { Maybe } from "../../../utils/utilTypes"; -import Table from "../../Table"; +import React, { Fragment } from "react"; +import Table from "components/Table"; +import { + useCancelScrimmage, + useScrimmageOutboxList, +} from "api/compete/useCompete"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEpisodeId } from "contexts/EpisodeContext"; +import TableBottom from "components/TableBottom"; +import Button from "components/elements/Button"; +import TeamWithRating from "components/compete/TeamWithRating"; +import { stringifyPlayerOrder } from "utils/utilTypes"; +import Pill from "components/elements/Pill"; +import { dateTime } from "utils/dateTime"; interface OutboxTableProps { - data: Maybe; - loading: boolean; + outboxPage: number; + handlePage: (page: number, key: "outboxPage") => void; } -const OutboxTable: React.FC = ({ data, loading }) => { +const OutboxTable: React.FC = ({ + outboxPage, + handlePage, +}) => { + const { episodeId } = useEpisodeId(); + const queryClient = useQueryClient(); + const outboxData = useScrimmageOutboxList( + { episodeId, page: outboxPage }, + queryClient, + ); + + const cancel = useCancelScrimmage({ episodeId }, queryClient); + return ( -
req.id.toString()} - columns={[ - { - header: "Team", - key: "requestee", - value: (req) => req.requested_to_name, - }, - { - header: "", - key: "cancel", - value: (req) => "CANCEL", - }, - ]} - /> + +

+ Outgoing Scrimmage Requests +

+
req.id.toString()} + bottomElement={ + { + handlePage(page, "outboxPage"); + }} + /> + } + columns={[ + { + header: "Team", + key: "requestee", + value: (req) => ( + + ), + }, + { + header: "Player Order", + key: "player_order", + value: (req) => stringifyPlayerOrder(req.player_order), + }, + { + header: "Maps", + key: "maps", + value: (req) => ( +
+ {req.maps.map((mapItem) => ( + + ))} +
+ ), + }, + { + header: "Requested At", + key: "requested_at", + value: (req) => dateTime(req.created).localFullString, + }, + { + header: "", + key: "cancel", + value: (req) => ( +
+
+ ), + }, + ]} + /> + ); }; diff --git a/frontend2/src/components/tables/scrimmaging/RequestScrimModal.tsx b/frontend2/src/components/tables/scrimmaging/RequestScrimModal.tsx new file mode 100644 index 000000000..2d00b35ad --- /dev/null +++ b/frontend2/src/components/tables/scrimmaging/RequestScrimModal.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + type GameMap, + Status526Enum, + type TeamPublic, + PlayerOrderEnum, +} from "api/_autogen"; +import { useRequestScrimmage } from "api/compete/useCompete"; +import Modal from "components/Modal"; +import { useEpisodeId } from "contexts/EpisodeContext"; +import { clone } from "lodash"; +import SelectMenu from "components/elements/SelectMenu"; +import SelectMultipleMenu from "components/elements/SelectMultipleMenu"; +import Button from "components/elements/Button"; +import DescriptiveCheckbox, { + CheckboxState, +} from "components/elements/DescriptiveCheckbox"; + +interface RequestScrimModalProps { + teamToRequest: TeamPublic; + maps: GameMap[]; + isOpen: boolean; + closeModal: () => void; +} + +// Team statuses that allow for ranked scrims. +const ALLOWS_RANKED = Status526Enum.R; +const MAX_MAPS_PER_SCRIM = 10; +const ORDER_OPTIONS = [ + { label: "Alternating", value: PlayerOrderEnum.QuestionMark }, + { label: "Requestor First", value: PlayerOrderEnum.Plus }, + { label: "Requestor Last", value: PlayerOrderEnum.Minus }, +]; + +const RequestScrimModal: React.FC = ({ + teamToRequest, + maps, + isOpen, + closeModal, +}) => { + const { episodeId } = useEpisodeId(); + const queryClient = useQueryClient(); + const request = useRequestScrimmage({ episodeId }, queryClient); + + const getRandomMaps: () => string[] = useCallback(() => { + const possibleMaps = clone(maps); + // Pick a random subset of 3 maps, assuming that there are at least 3 possible maps. + const randomMaps: GameMap[] = []; + for (let i = 0; i < 3; i++) { + const randomIndex = Math.floor(Math.random() * possibleMaps.length); + const randomMap = possibleMaps[randomIndex]; + randomMaps.push(randomMap); + possibleMaps.splice(randomIndex, 1); + } + return randomMaps.map((map) => map.name); + }, [maps]); + + const [selectedOrder, setSelectedOrder] = useState( + PlayerOrderEnum.QuestionMark, + ); + + const [selectedMapNames, setSelectedMapNames] = useState( + getRandomMaps(), + ); + const [mapErrorMessage, setMapErrorMessage] = useState(); + const [ranked, setRanked] = useState(false); + + return ( + + { +
+ { + setSelectedOrder(newOrder); + }} + /> + + errorMessage={mapErrorMessage} + disabled={ranked} + label="Select Maps" + placeholder={ranked ? "Random 3 maps!" : "Select up to 10 maps..."} + options={ + maps.map((map) => ({ + value: map.name, + label: map.name, + })) ?? [] + } + value={selectedMapNames} + onChange={(newMapNames) => { + if (newMapNames.length > MAX_MAPS_PER_SCRIM) { + setMapErrorMessage("You can only select up to 10 maps."); + return; + } + setMapErrorMessage(undefined); + setSelectedMapNames(newMapNames); + }} + /> +
+ } +
+ ); +}; + +export default RequestScrimModal; diff --git a/frontend2/src/components/tables/scrimmaging/ScrimHistoryTable.tsx b/frontend2/src/components/tables/scrimmaging/ScrimHistoryTable.tsx index 91a1421f0..1a066e3ce 100644 --- a/frontend2/src/components/tables/scrimmaging/ScrimHistoryTable.tsx +++ b/frontend2/src/components/tables/scrimmaging/ScrimHistoryTable.tsx @@ -1,105 +1,133 @@ -import React from "react"; +import React, { Fragment } from "react"; import { NavLink } from "react-router-dom"; import { dateTime } from "../../../utils/dateTime"; -import type { PaginatedMatchList } from "../../../api/_autogen"; -import type { Maybe } from "../../../utils/utilTypes"; import Table from "../../Table"; import TableBottom from "../../TableBottom"; import MatchScore from "../../compete/MatchScore"; import MatchStatus from "../../compete/MatchStatus"; -import RatingDelta from "../../compete/RatingDelta"; -import { useEpisodeInfo } from "../../../api/episode/useEpisode"; -import { useEpisodeId } from "../../../contexts/EpisodeContext"; -import { useUserTeam } from "../../../api/team/useTeam"; +import RatingDelta from "../../compete/MatchRatingDelta"; +import { useEpisodeInfo } from "api/episode/useEpisode"; +import { useEpisodeId } from "contexts/EpisodeContext"; +import { useUserTeam } from "api/team/useTeam"; import { isNil } from "lodash"; +import { useUserScrimmageList } from "api/compete/useCompete"; +import { useQueryClient } from "@tanstack/react-query"; interface ScrimHistoryTableProps { - data: Maybe; - page: number; - loading: boolean; - handlePage: (page: number) => void; + scrimsPage: number; + handlePage: (page: number, key: "scrimsPage") => void; } const ScrimHistoryTable: React.FC = ({ - data, - page, - loading, + scrimsPage, handlePage, }) => { const { episodeId } = useEpisodeId(); - const { data: episode } = useEpisodeInfo({ id: episodeId }); - const { data: currentTeam } = useUserTeam({ episodeId }); + const queryClient = useQueryClient(); + const episodeData = useEpisodeInfo({ id: episodeId }); + const userTeamData = useUserTeam({ episodeId }); + const scrimsData = useUserScrimmageList( + { episodeId, page: scrimsPage }, + queryClient, + ); return ( -
match.id.toString()} - bottomElement={ - - } - columns={[ - { - header: "Score", - key: "score", - value: (match) => { - return ; + +

+ Scrimmage History +

+
match.id.toString()} + bottomElement={ + { + handlePage(page, "scrimsPage"); + }} + /> + } + columns={[ + { + header: "Score", + key: "score", + value: (match) => { + return ( + + ); + }, + }, + { + header: "Rating (Δ)", + key: "rating", + value: (match) => { + const userTeam = match.participants?.find( + (p) => p.team === userTeamData.data?.id, + ); + if (userTeam === undefined) return; + return ( + + ); + }, + }, + { + header: "Opponent (Δ)", + key: "opponent", + value: (match) => { + const opponent = match.participants?.find( + (p) => + userTeamData.isSuccess && p.team !== userTeamData.data.id, + ); + if (opponent === undefined) return; + return ( + + ); + }, + }, + { + header: "Ranked", + key: "ranked", + value: (match) => (match.is_ranked ? "Ranked" : "Unranked"), + }, + { + header: "Status", + key: "status", + value: (match) => , + }, + { + header: "Replay", + key: "replay", + value: (match) => + !episodeData.isSuccess || isNil(match.replay_url) ? ( + <> + ) : ( + + Replay! + + ), }, - }, - { - header: "Opponent (Δ)", - key: "opponent", - value: (match) => { - const opponent = match.participants?.find( - (p) => currentTeam !== undefined && p.team !== currentTeam.id, - ); - if (opponent === undefined) return; - return ( - - ); + { + header: "Created", + key: "created", + value: (match) => dateTime(match.created).localFullString, }, - }, - { - header: "Ranked", - key: "ranked", - value: (match) => (match.is_ranked ? "Ranked" : "Unranked"), - }, - { - header: "Status", - key: "status", - value: (match) => , - }, - { - header: "Replay", - key: "replay", - value: (match) => - isNil(episode) || isNil(match.replay_url) ? ( - <> - ) : ( - - Replay! - - ), - }, - { - header: "Created", - key: "created", - value: (match) => dateTime(match.created).localFullString, - }, - ]} - /> + ]} + /> + ); }; diff --git a/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx b/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx index 00ffaad8e..83d7c2728 100644 --- a/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx +++ b/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx @@ -1,15 +1,21 @@ -import React from "react"; +import React, { Fragment, useCallback, useState } from "react"; import { NavLink } from "react-router-dom"; -import type { PaginatedTeamPublicList } from "../../../api/_autogen"; -import type { Maybe } from "../../../utils/utilTypes"; -import Table from "../../Table"; -import TableBottom from "../../TableBottom"; +import Table from "components/Table"; +import TableBottom from "components/TableBottom"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEpisodeId } from "contexts/EpisodeContext"; +import { useSearchTeams, useUserTeam } from "api/team/useTeam"; +import Input from "components/elements/Input"; +import Button from "components/elements/Button"; +import { type TeamPublic } from "api/_autogen"; +import RequestScrimModal from "./RequestScrimModal"; +import { useEpisodeInfo, useEpisodeMaps } from "api/episode/useEpisode"; interface TeamsTableProps { - data: Maybe; - page: number; - loading: boolean; - handlePage: (page: number) => void; + search: string; + teamsPage: number; + handlePage: (page: number, key: "teamsPage") => void; + handleSearch: (search: string) => void; } function trimString(str: string, maxLength: number): string { @@ -20,86 +26,177 @@ function trimString(str: string, maxLength: number): string { } const TeamsTable: React.FC = ({ - data, - page, - loading, + search, + teamsPage, handlePage, + handleSearch, }) => { + const { episodeId } = useEpisodeId(); + const episodeInfo = useEpisodeInfo({ id: episodeId }); + const queryClient = useQueryClient(); + const userTeam = useUserTeam({ episodeId }); + const teamsData = useSearchTeams( + { episodeId, search, page: teamsPage }, + queryClient, + ); + const maps = useEpisodeMaps({ episodeId }); + + const [searchText, setSearchText] = useState(search); + const [teamToRequest, setTeamToRequest] = useState(null); + + const canRequestScrimmage: (team: TeamPublic) => boolean = useCallback( + (team) => { + // TODO: Hack -> has_active_submission should be a boolean! Some sort of bug in API generation. + const hasActiveSubmission = + typeof team.has_active_submission === "boolean" + ? team.has_active_submission + : team.has_active_submission === "true"; + return ( + userTeam.isSuccess && + episodeInfo.isSuccess && + !episodeInfo.data.frozen && + userTeam.data.id !== team.id && + hasActiveSubmission && + team.members.length > 0 + ); + }, + [userTeam], + ); + return ( -
team.id.toString()} - bottomElement={ - +

+ Find a team to scrimmage! +

+
+ { + setSearchText(ev.target.value); + }} + onKeyDown={(ev) => { + if ( + (ev.key === "Enter" || ev.key === "NumpadEnter") && + search !== searchText && + !teamsData.isLoading + ) { + handleSearch(searchText); + } + }} + /> +
+
+
team.id.toString()} + bottomElement={ + { + handlePage(page, "teamsPage"); + }} + /> + } + columns={[ + { + header: "Rating", + key: "rating", + value: (team) => Math.round(team.profile?.rating ?? 0), + }, + { + header: "Team", + key: "team", + value: (team) => ( + + {trimString(team.name, 13)} + + ), + }, + { + header: "Members", + key: "members", + value: (team) => + team.members.map((member, idx) => ( + <> + + {trimString(member.username, 13)} + + {idx !== team.members.length - 1 ? ", " : ""} + + )), + }, + { + header: "Quote", + key: "quote", + value: (team) => team.profile?.quote ?? "", + }, + { + header: "Auto-Accept Ranked", + key: "auto_accept_ranked", + value: (team) => + team.profile?.auto_accept_ranked !== undefined && + team.profile.auto_accept_ranked + ? "Yes" + : "No", + }, + { + header: "Auto-Accept Unranked", + key: "auto_accept_unranked", + value: (team) => + team.profile?.auto_accept_unranked !== undefined && + team.profile?.auto_accept_unranked + ? "Yes" + : "No", + }, + { + header: "", + key: "request", + value: (team) => + userTeam.isSuccess && + team.id !== userTeam.data.id && ( +
match.id.toString()} - bottomElement={ - - } - columns={[ - { - header: "Score", - key: "score", - value: (match) => { - return ; + +

+ Recent Tournament Matches +

+
match.id.toString()} + bottomElement={ + { + handlePage(page, "tourneyPage"); + }} + /> + } + columns={[ + { + header: "Score", + key: "score", + value: (match) => { + return ( + + ); + }, + }, + { + header: "Opponent", + key: "opponent", + value: (match) => { + const opponent = match.participants?.find( + (p) => + userTeamData.isSuccess && p.team !== userTeamData.data.id, + ); + if (opponent === undefined) return; + return ( + + {opponent.teamname} + + ); + }, + }, + { + header: "Ranked", + key: "ranked", + value: (match) => (match.is_ranked ? "Ranked" : "Unranked"), + }, + { + header: "Status", + key: "status", + value: (match) => , + }, + { + header: "Replay", + key: "replay", + value: (match) => + !episodeData.isSuccess || isNil(match.replay_url) ? ( + <> + ) : ( + + Replay! + + ), }, - }, - { - header: "Opponent (Δ)", - key: "opponent", - value: (match) => { - const opponent = match.participants?.find( - (p) => currentTeam !== undefined && p.team !== currentTeam.id, - ); - if (opponent === undefined) return; - return ( - - ); + { + header: "Created", + key: "created", + value: (match) => dateTime(match.created).localFullString, }, - }, - { - header: "Ranked", - key: "ranked", - value: (match) => (match.is_ranked ? "Ranked" : "Unranked"), - }, - { - header: "Status", - key: "status", - value: (match) => , - }, - { - header: "Replay", - key: "replay", - value: (match) => - isNil(episode) || isNil(match.replay_url) ? ( - <> - ) : ( - - Replay! - - ), - }, - { - header: "Created", - key: "created", - value: (match) => dateTime(match.created).localFullString, - }, - ]} - /> + ]} + /> + ); }; diff --git a/frontend2/src/utils/utilTypes.ts b/frontend2/src/utils/utilTypes.ts index 8d34e166b..5305b718f 100644 --- a/frontend2/src/utils/utilTypes.ts +++ b/frontend2/src/utils/utilTypes.ts @@ -1,3 +1,5 @@ +import { PlayerOrderEnum } from "api/_autogen"; + export type Maybe = T | undefined; /** @@ -8,3 +10,21 @@ export type Maybe = T | undefined; export function isPresent(val: T | undefined | null): val is T { return val !== undefined && val !== null; } + +/** + * Helper function to stringify a PlayerOrderEnum. + * @param order PlayerOrderEnum to stringify. + * @returns Description of the player order. + */ +export const stringifyPlayerOrder = (order: PlayerOrderEnum): string => { + switch (order) { + case PlayerOrderEnum.QuestionMark: + return "Alternating"; + case PlayerOrderEnum.Plus: + return "Requestor First"; + case PlayerOrderEnum.Minus: + return "Requestor Last"; + default: + return "???"; + } +}; diff --git a/frontend2/src/views/MyTeam.tsx b/frontend2/src/views/MyTeam.tsx index 31e75ed21..148248308 100644 --- a/frontend2/src/views/MyTeam.tsx +++ b/frontend2/src/views/MyTeam.tsx @@ -1,18 +1,18 @@ import React, { type EventHandler, useMemo, useState } from "react"; -import { PageTitle } from "../components/elements/BattlecodeStyle"; -import SectionCard from "../components/SectionCard"; -import Input from "../components/elements/Input"; -import TextArea from "../components/elements/TextArea"; -import Button from "../components/elements/Button"; +import { PageTitle } from "components/elements/BattlecodeStyle"; +import SectionCard from "components/SectionCard"; +import Input from "components/elements/Input"; +import TextArea from "components/elements/TextArea"; +import Button from "components/elements/Button"; import MemberList from "../components/team/MemberList"; -import Modal from "../components/Modal"; -import EligibilitySettings from "../components/team/EligibilitySettings"; -import ScrimmageSettings from "../components/team/ScrimmageSettings"; -import { useEpisodeId } from "../contexts/EpisodeContext"; -import { useLeaveTeam, useUpdateTeam, useUserTeam } from "../api/team/useTeam"; +import Modal from "components/Modal"; +import EligibilitySettings from "components/team/EligibilitySettings"; +import ScrimmageSettings from "components/team/ScrimmageSettings"; +import { useEpisodeId } from "contexts/EpisodeContext"; +import { useLeaveTeam, useUpdateTeam, useUserTeam } from "api/team/useTeam"; import { useQueryClient } from "@tanstack/react-query"; import JoinTeam from "./JoinTeam"; -import Loading from "../components/Loading"; +import Loading from "components/Loading"; import { type SubmitHandler, useForm } from "react-hook-form"; interface InfoFormInput { diff --git a/frontend2/src/views/Scrimmaging.tsx b/frontend2/src/views/Scrimmaging.tsx index 6a074be78..bd3d4b4f1 100644 --- a/frontend2/src/views/Scrimmaging.tsx +++ b/frontend2/src/views/Scrimmaging.tsx @@ -1,23 +1,12 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { useSearchParams } from "react-router-dom"; -import Input from "../components/elements/Input"; -import Button from "../components/elements/Button"; -import { useEpisodeId } from "../contexts/EpisodeContext"; -import Collapse from "../components/elements/Collapse"; -import InboxTable from "../components/tables/scrimmaging/InboxTable"; -import OutboxTable from "../components/tables/scrimmaging/OutboxTable"; -import { getParamEntries, parsePageParam } from "../utils/searchParamHelpers"; -import TeamsTable from "../components/tables/scrimmaging/TeamsTable"; -import TournamentMatchesTable from "../components/tables/scrimmaging/TournamentMatchesTable"; -import ScrimHistoryTable from "../components/tables/scrimmaging/ScrimHistoryTable"; -import { - useScrimmageInboxList, - useScrimmageOutboxList, - useTournamentMatchList, - useUserScrimmageList, -} from "../api/compete/useCompete"; -import { useSearchTeams, useUserTeam } from "../api/team/useTeam"; -import { useQueryClient } from "@tanstack/react-query"; +import InboxTable from "components/tables/scrimmaging/InboxTable"; +import OutboxTable from "components/tables/scrimmaging/OutboxTable"; +import { getParamEntries, parsePageParam } from "utils/searchParamHelpers"; +import TeamsTable from "components/tables/scrimmaging/TeamsTable"; +import ScrimHistoryTable from "components/tables/scrimmaging/ScrimHistoryTable"; +import { Tab } from "@headlessui/react"; +import TournamentMatchesTable from "components/tables/scrimmaging/TournamentMatchesTable"; interface QueryParams { inboxPage: number; @@ -29,8 +18,19 @@ interface QueryParams { } const Scrimmaging: React.FC = () => { - const { episodeId } = useEpisodeId(); - const queryClient = useQueryClient(); + function classNames(...classes: string[]): string { + return classes.filter(Boolean).join(" "); + } + + const tabClassName = ({ selected }: { selected: boolean }): string => { + return classNames( + "w-full rounded-lg py-2.5 text-sm font-medium leading-5", + "ring-white/60 ring-offset-2 ring-offset-cyan-400 focus:outline-none focus:ring-2", + selected + ? "bg-white text-cyan-700 shadow" + : "text-cyan-100 hover:bg-white/[0.12] hover:text-white", + ); + }; const [searchParams, setSearchParams] = useSearchParams(); const queryParams: QueryParams = useMemo(() => { @@ -44,56 +44,12 @@ const Scrimmaging: React.FC = () => { }; }, [searchParams]); - const teamData = useUserTeam({ episodeId }); - const { data: inboxData, isLoading: inboxLoading } = useScrimmageInboxList( - { - episodeId, - page: queryParams.inboxPage, - }, - queryClient, - ); - const { data: outboxData, isLoading: outboxLoading } = useScrimmageOutboxList( - { - episodeId, - page: queryParams.outboxPage, - }, - queryClient, - ); - const { data: teamsData, isLoading: teamsLoading } = useSearchTeams( - { - episodeId, - search: queryParams.search, - page: queryParams.teamsPage, - }, - queryClient, - ); - const { data: scrimsData, isLoading: scrimsLoading } = useUserScrimmageList( - { - episodeId, - page: queryParams.scrimsPage, - }, - queryClient, - ); - const { data: tourneyData, isLoading: tourneyLoading } = - useTournamentMatchList( - { - episodeId, - teamId: teamData.data?.id, - page: queryParams.tourneyPage, - }, - queryClient, - ); - - const [searchText, setSearchText] = useState(queryParams.search); - - function handleSearch(): void { - if (!teamsLoading && searchText !== queryParams.search) { - setSearchParams((prev) => ({ - ...getParamEntries(prev), - teamsPage: "1", - search: searchText, - })); - } + function handleSearch(search: string): void { + setSearchParams((prev) => ({ + ...getParamEntries(prev), + teamsPage: "1", + search, + })); } /** @@ -112,84 +68,78 @@ const Scrimmaging: React.FC = () => { return (
-

+

Scrimmaging

- - - -
- - - -
- -

- Find a team to scrimmage! -

-
- { - setSearchText(ev.target.value); - }} - onKeyDown={(ev) => { - if (ev.key === "Enter" || ev.key === "NumpadEnter") { - handleSearch(); - } - }} - /> -
-
-
- { - handlePage(page, "teamsPage"); - }} - /> -
- -

- Scrimmage History -

-
- { - handlePage(page, "scrimsPage"); - }} - /> -
- -

- Recent Tournament Matches -

-
- { - handlePage(page, "tourneyPage"); - }} - /> -
+ + + Inbox + Outbox + Find Teams + Scrim History + Tournament Matches + + + + + + + + + + + + + + + + + + +
); }; diff --git a/frontend2/tsconfig.json b/frontend2/tsconfig.json index 9d379a3c4..545c6550c 100644 --- a/frontend2/tsconfig.json +++ b/frontend2/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "baseUrl": "src", "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true,