Tournament: Add subs to roster mid-tournament

This commit is contained in:
Kalle 2023-06-05 23:50:44 +03:00
parent ad1b1fdf84
commit 337f97e6c6
16 changed files with 206 additions and 46 deletions

View File

@ -4,7 +4,7 @@ export function Divider({
children,
className,
}: {
children: React.ReactNode;
children?: React.ReactNode;
className?: string;
}) {
return <div className={clsx("divider", className)}>{children}</div>;

View File

@ -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}
</HeadlessPopover.Button>
@ -44,7 +50,7 @@ export function Popover({
<HeadlessPopover.Panel
// @ts-expect-error Popper docs: https://popper.js.org/react-popper/v2/
ref={setPopperElement}
className="popover-content"
className={clsx("popover-content", contentClassName)}
style={styles["popper"]}
{...attributes["popper"]}
>

View File

@ -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 ? (
<TournamentProgressPrompt ownedTeamId={myTeam.id} />
) : null}
{/* TODO: also hide this if out of the tournament */}
{!data.finalStandings &&
myTeam &&
parentRouteData.hasStarted &&
parentRouteData.ownTeam ? (
<AddSubsPopOver
members={myTeam.members}
inviteCode={parentRouteData.ownTeam.inviteCode}
/>
) : null}
{data.finalStandings ? (
<FinalStandings standings={data.finalStandings} />
) : null}
@ -473,6 +487,55 @@ function TournamentProgressPrompt({ ownedTeamId }: { ownedTeamId: number }) {
);
}
function AddSubsPopOver({
members,
inviteCode,
}: {
members: unknown[];
inviteCode: string;
}) {
const parentRouteData = useOutletContext<TournamentLoaderData>();
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 (
<Popover
buttonChildren={<>{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 ? (
<>
<Divider className="my-2" />
<div>{t("tournament:actions.shareLink", { inviteLink })}</div>
<div className="my-2 flex justify-center">
<Button
size="tiny"
onClick={() => copyToClipboard(inviteLink)}
variant="minimal"
className="tiny"
testId="copy-invite-link-button"
>
{t("common:actions.copyToClipboard")}
</Button>
</div>
</>
) : null}
</Popover>
);
}
function FinalStandings({ standings }: { standings: FinalStanding[] }) {
const parentRouteData = useOutletContext<TournamentLoaderData>();
const [viewAll, setViewAll] = React.useState(false);

View File

@ -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"

View File

@ -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<TournamentTeamMember, "userId" | "isOwner"> &

View File

@ -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(
({

View File

@ -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;
}

View File

@ -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)) {

View File

@ -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<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const teamRegularMemberOf = parentRouteData.teams.find((team) =>
@ -297,7 +294,7 @@ export default function TournamentRegisterPage() {
{teamRegularMemberOf ? (
<Alert>You are in a team for this event</Alert>
) : (
<RegistrationForms ownTeam={data?.ownTeam} />
<RegistrationForms ownTeam={parentRouteData?.ownTeam} />
)}
</div>
);
@ -316,7 +313,7 @@ function PleaseLogIn() {
function RegistrationForms({
ownTeam,
}: {
ownTeam?: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
ownTeam?: TournamentLoaderData["ownTeam"];
}) {
const data = useLoaderData<typeof loader>();
const user = useUser();
@ -600,13 +597,13 @@ function TeamInfo({
function FillRoster({
ownTeam,
}: {
ownTeam: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
ownTeam: NonNullable<TournamentLoaderData["ownTeam"]>;
}) {
const data = useLoaderData<typeof loader>();
const user = useUser();
const parentRouteData = useOutletContext<TournamentLoaderData>();
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 (
<div>
@ -658,7 +658,7 @@ function FillRoster({
{!teamIsFull ? (
<div className="stack md items-center">
<div className="text-center text-sm">
Share your invite link to add members: {inviteLink}
{t("tournament:actions.shareLink", { inviteLink })}
</div>
<div>
<Button
@ -708,7 +708,8 @@ function FillRoster({
</section>
<div className="tournament__section__warning">
At least {TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL} members are required to
participate. Max roster size is {TOURNAMENT.TEAM_MAX_MEMBERS}.
participate. Max roster size is{" "}
{TOURNAMENT.TEAM_MAX_MEMBERS_BEFORE_START}.
</div>
</div>
);

View File

@ -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,

View File

@ -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;

View File

@ -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)
);
}

View File

@ -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);
}

View File

@ -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/);
});
});

View File

@ -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 */

View File

@ -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"
}