diff --git a/app/components/Divider.tsx b/app/components/Divider.tsx index f6c547855..ad8ae50d5 100644 --- a/app/components/Divider.tsx +++ b/app/components/Divider.tsx @@ -4,7 +4,7 @@ export function Divider({ children, className, }: { - children: React.ReactNode; + children?: React.ReactNode; className?: string; }) { return
{children}
; diff --git a/app/components/Popover.tsx b/app/components/Popover.tsx index 08da170be..4b21513bf 100644 --- a/app/components/Popover.tsx +++ b/app/components/Popover.tsx @@ -1,5 +1,6 @@ import { Popover as HeadlessPopover } from "@headlessui/react"; import type { Placement } from "@popperjs/core"; +import clsx from "clsx"; import * as React from "react"; import { usePopper } from "react-popper"; @@ -8,13 +9,17 @@ export function Popover({ children, buttonChildren, triggerClassName, + triggerTestId, containerClassName, + contentClassName, placement, }: { children: React.ReactNode; buttonChildren: React.ReactNode; triggerClassName?: string; + triggerTestId?: string; containerClassName?: string; + contentClassName?: string; placement?: Placement; }) { const [referenceElement, setReferenceElement] = React.useState(); @@ -37,6 +42,7 @@ export function Popover({ // @ts-expect-error Popper docs: https://popper.js.org/react-popper/v2/ ref={setReferenceElement} className={triggerClassName ?? "minimal tiny"} + data-testid={triggerTestId} > {buttonChildren} @@ -44,7 +50,7 @@ export function Popover({ diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 4c6d85842..8b5c98d2f 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -23,7 +23,9 @@ import hasTournamentStarted from "../../tournament/queries/hasTournamentStarted. import { findByIdentifier } from "../../tournament/queries/findByIdentifier.server"; import { notFoundIfFalsy, validate } from "~/utils/remix"; import { + SENDOU_INK_BASE_URL, tournamentBracketsSubscribePage, + tournamentJoinPage, tournamentMatchPage, tournamentTeamPage, userPage, @@ -60,6 +62,8 @@ import { removeDuplicates } from "~/utils/arrays"; import { Flag } from "~/components/Flag"; import { databaseTimestampToDate } from "~/utils/dates"; import { Popover } from "~/components/Popover"; +import { useCopyToClipboard } from "react-use"; +import { useTranslation } from "~/hooks/useTranslation"; export const links: LinksFunction = () => { return [ @@ -324,6 +328,16 @@ export default function TournamentBracketsPage() { {parentRouteData.hasStarted && myTeam ? ( ) : null} + {/* TODO: also hide this if out of the tournament */} + {!data.finalStandings && + myTeam && + parentRouteData.hasStarted && + parentRouteData.ownTeam ? ( + + ) : null} {data.finalStandings ? ( ) : null} @@ -473,6 +487,55 @@ function TournamentProgressPrompt({ ownedTeamId }: { ownedTeamId: number }) { ); } +function AddSubsPopOver({ + members, + inviteCode, +}: { + members: unknown[]; + inviteCode: string; +}) { + const parentRouteData = useOutletContext(); + const { t } = useTranslation(["common", "tournament"]); + const [, copyToClipboard] = useCopyToClipboard(); + + const subsAvailableToAdd = + TOURNAMENT.TEAM_MAX_MEMBERS_BEFORE_START + 1 - members.length; + + const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({ + eventId: parentRouteData.event.id, + inviteCode, + })}`; + + return ( + {t("tournament:actions.addSub")}} + triggerClassName="tiny outlined ml-auto" + triggerTestId="add-sub-button" + containerClassName="mt-4" + contentClassName="text-xs" + > + {t("tournament:actions.sub.prompt", { count: subsAvailableToAdd })} + {subsAvailableToAdd > 0 ? ( + <> + +
{t("tournament:actions.shareLink", { inviteLink })}
+
+ +
+ + ) : null} +
+ ); +} + function FinalStandings({ standings }: { standings: FinalStanding[] }) { const parentRouteData = useOutletContext(); const [viewAll, setViewAll] = React.useState(false); diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx index f1b2da508..118a72fcd 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -429,7 +429,7 @@ function MapListSection({ teams }: { teams: [id: number, id: number] }) { canReportTournamentScore({ event: parentRouteData.event, match: data.match, - ownedTeamId: parentRouteData.ownedTeamId, + ownedTeamId: parentRouteData.ownTeam?.id, user, }) ? "EDIT" diff --git a/app/features/tournament/queries/findTeamsByTournamentId.server.ts b/app/features/tournament/queries/findTeamsByTournamentId.server.ts index 97e8b4bba..b3ef7b97e 100644 --- a/app/features/tournament/queries/findTeamsByTournamentId.server.ts +++ b/app/features/tournament/queries/findTeamsByTournamentId.server.ts @@ -53,7 +53,7 @@ export interface FindTeamsByTournamentIdItem { id: TournamentTeam["id"]; name: TournamentTeam["name"]; seed: TournamentTeam["seed"]; - checkedInAt: TournamentTeamCheckIn["checkedInAt"]; + checkedInAt: TournamentTeamCheckIn["checkedInAt"] | null; prefersNotToHost: TournamentTeam["prefersNotToHost"]; members: Array< Pick & diff --git a/app/features/tournament/queries/joinLeaveTeam.server.ts b/app/features/tournament/queries/joinLeaveTeam.server.ts index f9ed729f9..9f14a6b68 100644 --- a/app/features/tournament/queries/joinLeaveTeam.server.ts +++ b/app/features/tournament/queries/joinLeaveTeam.server.ts @@ -23,7 +23,6 @@ const deleteMemberStm = sql.prepare(/*sql*/ ` and "userId" = @userId `); -// TODO: divide this to different queries and compose in route // TODO: if captain leaves don't delete but give captain to someone else export const joinTeam = sql.transaction( ({ diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index fe3d6de58..f0c2afe2a 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -111,19 +111,31 @@ export const action: ActionFunction = async ({ request, params }) => { }); break; } + // TODO: could also handle the case of admin trying + // to add members from a checked in team case "ADD_MEMBER": { const team = teams.find((t) => t.id === data.teamId); validate(team, "Invalid team id"); - validate( - !teams.some((t) => - t.members.some((m) => m.userId === data["user[value]"]) - ), - "User is already on a team" + + const previousTeam = teams.find((t) => + t.members.some((m) => m.userId === data["user[value]"]) ); + if (hasTournamentStarted(event.id)) { + validate( + !previousTeam || !previousTeam.checkedInAt, + "User is already on a checked in team" + ); + } else { + validate(!previousTeam, "User is already on a team"); + } + joinTeam({ userId: data["user[value]"], newTeamId: team.id, + previousTeamId: previousTeam?.id, + // this team is not checked in so we can simply delete it + whatToDoWithPreviousTeam: previousTeam ? "DELETE" : undefined, }); break; } diff --git a/app/features/tournament/routes/to.$id.join.tsx b/app/features/tournament/routes/to.$id.join.tsx index be972a147..a310a5579 100644 --- a/app/features/tournament/routes/to.$id.join.tsx +++ b/app/features/tournament/routes/to.$id.join.tsx @@ -19,8 +19,13 @@ import React from "react"; import { discordFullName } from "~/utils/strings"; import { joinSchema } from "../tournament-schemas.server"; import { giveTrust } from "../queries/giveTrust.server"; +import { + tournamentIdFromParams, + tournamentTeamMaxSize, +} from "../tournament-utils"; -export const action: ActionFunction = async ({ request }) => { +export const action: ActionFunction = async ({ request, params }) => { + const tournamentId = tournamentIdFromParams(params); const user = await requireUserId(request); const url = new URL(request.url); const inviteCode = url.searchParams.get("code"); @@ -30,20 +35,28 @@ export const action: ActionFunction = async ({ request }) => { const leanTeam = notFoundIfFalsy(findByInviteCode(inviteCode)); const teams = findTeamsByTournamentId(leanTeam.tournamentId); - validate( - !hasTournamentStarted(leanTeam.tournamentId), - "Tournament has started" - ); - const teamToJoin = teams.find((team) => team.id === leanTeam.id); const previousTeam = teams.find((team) => team.members.some((member) => member.userId === user.id) ); + const tournamentHasStarted = hasTournamentStarted(tournamentId); + + if (tournamentHasStarted) { + validate( + !previousTeam || !previousTeam.checkedInAt, + "Can't leave checked in team mid tournament" + ); + } validate(teamToJoin, "Not team of this tournament"); validate( - validateCanJoin({ inviteCode, teamToJoin, userId: user.id }) === "VALID", - "Invite code is invalid" + validateCanJoin({ + inviteCode, + teamToJoin, + userId: user.id, + tournamentHasStarted, + }) === "VALID", + "Cannot join this team or invite code is invalid" ); const whatToDoWithPreviousTeam = !previousTeam @@ -80,13 +93,15 @@ export const action: ActionFunction = async ({ request }) => { return redirect(tournamentPage(leanTeam.tournamentId)); }; -export const loader = ({ request }: LoaderArgs) => { +export const loader = ({ request, params }: LoaderArgs) => { + const tournamentId = tournamentIdFromParams(params); const url = new URL(request.url); const inviteCode = url.searchParams.get("code"); return { teamId: inviteCode ? findByInviteCode(inviteCode)?.id : null, inviteCode, + tournamentHasStarted: hasTournamentStarted(tournamentId), }; }; @@ -104,6 +119,7 @@ export default function JoinTeamPage() { inviteCode: data.inviteCode, teamToJoin, userId: user?.id, + tournamentHasStarted: data.tournamentHasStarted, }); const textPrompt = () => { @@ -162,10 +178,12 @@ function validateCanJoin({ inviteCode, teamToJoin, userId, + tournamentHasStarted, }: { inviteCode?: string | null; teamToJoin?: TournamentLoaderTeam; userId?: number; + tournamentHasStarted: boolean; }) { if (typeof inviteCode !== "string") { return "MISSING_CODE"; @@ -179,7 +197,9 @@ function validateCanJoin({ if (!teamToJoin) { return "NO_TEAM_MATCHING_CODE"; } - if (teamToJoin.members.length >= TOURNAMENT.TEAM_MAX_MEMBERS) { + if ( + teamToJoin.members.length >= tournamentTeamMaxSize(tournamentHasStarted) + ) { return "TEAM_FULL"; } if (teamToJoin.members.some((member) => member.userId === userId)) { diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 70b7c6316..e50a01fae 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -2,7 +2,6 @@ import { redirect, type ActionFunction, type LoaderArgs, - type SerializeFrom, } from "@remix-run/node"; import { useFetcher, useLoaderData, useOutletContext } from "@remix-run/react"; import clsx from "clsx"; @@ -232,7 +231,6 @@ export const loader = async ({ request, params }: LoaderArgs) => { if (!ownTeam) return null; return { - ownTeam, mapPool: findMapPoolByTeamId(ownTeam.id), trustedPlayers: findTrustedPlayers({ userId: user.id, @@ -245,7 +243,6 @@ export default function TournamentRegisterPage() { const isMounted = useIsMounted(); const { i18n } = useTranslation(); const user = useUser(); - const data = useLoaderData(); const parentRouteData = useOutletContext(); const teamRegularMemberOf = parentRouteData.teams.find((team) => @@ -297,7 +294,7 @@ export default function TournamentRegisterPage() { {teamRegularMemberOf ? ( You are in a team for this event ) : ( - + )} ); @@ -316,7 +313,7 @@ function PleaseLogIn() { function RegistrationForms({ ownTeam, }: { - ownTeam?: NonNullable>["ownTeam"]; + ownTeam?: TournamentLoaderData["ownTeam"]; }) { const data = useLoaderData(); const user = useUser(); @@ -600,13 +597,13 @@ function TeamInfo({ function FillRoster({ ownTeam, }: { - ownTeam: NonNullable>["ownTeam"]; + ownTeam: NonNullable; }) { const data = useLoaderData(); const user = useUser(); const parentRouteData = useOutletContext(); const [, copyToClipboard] = useCopyToClipboard(); - const { t } = useTranslation(["common"]); + const { t } = useTranslation(["common", "tournament"]); const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({ eventId: parentRouteData.event.id, @@ -626,7 +623,9 @@ function FillRoster({ ); const optionalMembers = Math.max( - TOURNAMENT.TEAM_MAX_MEMBERS - ownTeamMembers.length - missingMembers, + TOURNAMENT.TEAM_MAX_MEMBERS_BEFORE_START - + ownTeamMembers.length - + missingMembers, 0 ); @@ -643,7 +642,8 @@ function FillRoster({ }); })(); - const teamIsFull = ownTeamMembers.length >= TOURNAMENT.TEAM_MAX_MEMBERS; + const teamIsFull = + ownTeamMembers.length >= TOURNAMENT.TEAM_MAX_MEMBERS_BEFORE_START; return (
@@ -658,7 +658,7 @@ function FillRoster({ {!teamIsFull ? (
- Share your invite link to add members: {inviteLink} + {t("tournament:actions.shareLink", { inviteLink })}
); diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index 5f1452717..ddaff9084 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -26,6 +26,7 @@ import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.serv import hasTournamentStarted from "../queries/hasTournamentStarted.server"; import { teamHasCheckedIn, tournamentIdFromParams } from "../tournament-utils"; import styles from "../tournament.css"; +import { findOwnTeam } from "../queries/findOwnTeam.server"; export const shouldRevalidate: ShouldRevalidateFunction = (args) => { const wasMutation = args.formMethod === "post"; @@ -74,9 +75,6 @@ export const loader = async ({ params, request }: LoaderArgs) => { teams = teams.filter(teamHasCheckedIn); } - const ownedTeamId = teams.find((team) => - team.members.some((member) => member.userId === user?.id && member.isOwner) - )?.id; const teamMemberOfName = teams.find((team) => team.members.some((member) => member.userId === user?.id) )?.name; @@ -86,7 +84,12 @@ export const loader = async ({ params, request }: LoaderArgs) => { tieBreakerMapPool: db.calendarEvents.findTieBreakerMapPoolByEventId( event.eventId ), - ownedTeamId, + ownTeam: user + ? findOwnTeam({ + tournamentId, + userId: user.id, + }) + : null, teamMemberOfName, teams, hasStarted, diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index a1d2717ce..d9fe07742 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -4,7 +4,7 @@ export const TOURNAMENT = { COUNTERPICK_MAX_STAGE_REPEAT: 2, COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE: 6, TEAM_MIN_MEMBERS_FOR_FULL: 4, - TEAM_MAX_MEMBERS: 6, + TEAM_MAX_MEMBERS_BEFORE_START: 6, AVAILABLE_BEST_OF: [3, 5, 7] as const, ENOUGH_TEAMS_TO_START: 2, } as const; diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 734019260..ae7840176 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -138,3 +138,10 @@ export function tournamentRoundI18nKey(round: PlayedSet["round"]) { return `bracket.${round.type}` as const; } + +export function tournamentTeamMaxSize(tournamentHasStarted: boolean) { + // ensuring every team can add at least one sub while the tournament is ongoing + return ( + TOURNAMENT.TEAM_MAX_MEMBERS_BEFORE_START + Number(tournamentHasStarted) + ); +} diff --git a/app/styles/utils.css b/app/styles/utils.css index 8821e87ff..a35d019e4 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -170,6 +170,10 @@ margin-block-end: var(--s-1); } +.mb-4 { + margin-block-end: var(--s-4); +} + .ml-auto { margin-inline-start: auto; } @@ -190,6 +194,10 @@ margin-inline: var(--s-2); } +.my-2 { + margin-block: var(--s-2); +} + .my-4 { margin-block: var(--s-4); } diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index dee07c180..2a739d58c 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -1,17 +1,15 @@ import { type Page, test, expect } from "@playwright/test"; import { NZAP_TEST_ID } from "~/db/seed/constants"; -import { impersonate, navigate, seed } from "~/utils/playwright"; +import { impersonate, navigate, seed, submit } from "~/utils/playwright"; import { tournamentBracketsPage } from "~/utils/urls"; -const TOURNAMENT_ID = 2; - -const startBracket = async (page: Page) => { +const startBracket = async (page: Page, tournamentId = 2) => { await seed(page); await impersonate(page); await navigate({ page, - url: tournamentBracketsPage(TOURNAMENT_ID), + url: tournamentBracketsPage(tournamentId), }); await page.getByTestId("finalize-bracket-button").click(); @@ -63,24 +61,25 @@ const expectScore = (page: Page, score: [number, number]) => // 7) As N-ZAP, undo all scores and switch to different team sweeping test.describe("Tournament bracket", () => { test("reports score and sees bracket update", async ({ page }) => { + const tournamentId = 2; await startBracket(page); await impersonate(page, NZAP_TEST_ID); await navigate({ page, - url: tournamentBracketsPage(TOURNAMENT_ID), + url: tournamentBracketsPage(tournamentId), }); // 1) await page.locator('[data-match-id="5"]').click(); - await reportResult(page, 2); + await reportResult(page, tournamentId); await backToBracket(page); // 2) await impersonate(page); await navigate({ page, - url: tournamentBracketsPage(TOURNAMENT_ID), + url: tournamentBracketsPage(tournamentId), }); await page.locator('[data-match-id="6"]').click(); await reportResult(page, 2); @@ -112,7 +111,7 @@ test.describe("Tournament bracket", () => { await impersonate(page, 2); await navigate({ page, - url: tournamentBracketsPage(TOURNAMENT_ID), + url: tournamentBracketsPage(tournamentId), }); await page.locator('[data-match-id="5"]').click(); await page.getByTestId("undo-score-button").click(); @@ -123,4 +122,38 @@ test.describe("Tournament bracket", () => { page.locator("[data-round-id='5'] [data-participant-id='102']") ).toBeVisible(); }); + + test("adds a sub mid tournament (from non checked in team)", async ({ + page, + }) => { + const tournamentId = 1; + await startBracket(page, tournamentId); + + // captain of the first team + await impersonate(page, 5); + await navigate({ + page, + url: tournamentBracketsPage(tournamentId), + }); + + await page.getByTestId("add-sub-button").click(); + await page.getByTestId("copy-invite-link-button").click(); + + const inviteLinkProd: string = await page.evaluate( + "navigator.clipboard.readText()" + ); + const inviteLink = inviteLinkProd.replace( + "https://sendou.ink", + "http://localhost:5800" + ); + + await impersonate(page, NZAP_TEST_ID); + await navigate({ + page, + url: inviteLink, + }); + + await submit(page); + await expect(page).toHaveURL(/brackets/); + }); }); diff --git a/playwright.config.ts b/playwright.config.ts index 9e75dc14f..ea2df4cd0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -40,6 +40,8 @@ const config: PlaywrightTestConfig = { /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "retain-on-failure", + + permissions: ["clipboard-read"], }, /* Configure projects for major browsers */ diff --git a/public/locales/en/tournament.json b/public/locales/en/tournament.json index 8d3701a4d..c952a9b17 100644 --- a/public/locales/en/tournament.json +++ b/public/locales/en/tournament.json @@ -59,5 +59,11 @@ "bracket.grand_finals": "Grand Finals", "bracket.grand_finals.bracket_reset": "Bracket Reset", "bracket.main": "Main Bracket", - "bracket.underground": "Underground Bracket" + "bracket.underground": "Underground Bracket", + + "actions.addSub": "Add sub", + "actions.shareLink": "Share your invite link to add members: {{inviteLink}}", + "actions.sub.prompt_other": "You can still add {{count}} subs to your roster", + "actions.sub.prompt_one": "You can still add {{count}} sub to your roster", + "actions.sub.prompt_zero": "Your roster is full and more subs can't be added" }