diff --git a/app/features/tournament-admin/routes/to.$id.admin._index.tsx b/app/features/tournament-admin/routes/to.$id.admin._index.tsx index d3557aee6..85ccc6653 100644 --- a/app/features/tournament-admin/routes/to.$id.admin._index.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin._index.tsx @@ -16,6 +16,7 @@ import * as React from "react"; import { Link, useFetcher } from "react-router"; import { Avatar } from "~/components/Avatar"; import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; import { SendouPopover } from "~/components/elements/Popover"; import { FormWithConfirm } from "~/components/FormWithConfirm"; @@ -28,9 +29,12 @@ import { Table } from "~/components/Table"; import { useTournament } from "~/features/tournament/routes/to.$id"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import { addSubForUserFormSchema } from "~/features/tournament-lfg/tournament-lfg-schemas"; +import { SendouForm } from "~/form/SendouForm"; import { tournamentAdminRegistrationEditPage, tournamentAdminRegistrationPage, + tournamentSubsPage, tournamentTeamPage, userPage, } from "~/utils/urls"; @@ -81,6 +85,7 @@ export default function TournamentAdminTeamsPage() { Add new team ) : null} + {tournament.canAddNewSubPostAsOrganizer ? : null} + } + onPress={() => setDialogOpen(true)} + > + Add sub + + {dialogOpen ? ( + setDialogOpen(false)} + > + setDialogOpen(false)} + > + {({ FormField }) => ( + <> + + + + )} + + + ) : null} + + ); +} + function TeamRow({ team, maxRosterSize, diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 8ad8fed8e..c894cd01f 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -731,6 +731,15 @@ export class Tournament { ); } + /** Can the organizer add a new sub post on behalf of a user at this time? Unlike users + * the organizer is not limited by the registration closing early. */ + get canAddNewSubPostAsOrganizer() { + if (!this.lfgEnabled) return false; + if (this.isInvitational) return false; + + return !this.everyBracketOver; + } + /** what is the max amount of members teams can add in total? This limit doesn't apply to the organizer adding members to a team. */ get maxMembersPerTeam() { // special format diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts index 715918257..70ba2fc70 100644 --- a/app/features/tournament-lfg/actions/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts @@ -330,6 +330,49 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { break; } + case "ADD_SUB_FOR_USER": { + const tournament = await tournamentFromDBCached({ + tournamentId, + user, + }); + errorToastIfFalsy( + tournament.isOrganizer(user), + "Only tournament organizers can add subs for other users", + ); + errorToastIfFalsy( + tournament.canAddNewSubPostAsOrganizer, + "Cannot add sub post at this time", + ); + await requireNotBannedByOrganization({ + tournament, + user: { id: data.userId }, + message: "The user is banned from events hosted by this organization", + }); + + const targetTeam = tournament.teamMemberOfByUser({ id: data.userId }); + errorToastIfFalsy( + !targetTeam || targetTeam.droppedOut, + "User is already on a team", + ); + + const existingSubGroups = + await TournamentLFGRepository.findSubGroups(tournamentId); + errorToastIfFalsy( + !existingSubGroups.some((g) => + g.members.some((m) => m.id === data.userId), + ), + "User already has a sub post", + ); + + await TournamentLFGRepository.insertPlaceholderTeam({ + tournamentId, + userId: data.userId, + isStayAsSub: true, + lfgNote: data.message ?? undefined, + }); + + break; + } case "DELETE_SUB": { const tournament = await tournamentFromDBCached({ tournamentId, diff --git a/app/features/tournament-lfg/tournament-lfg-schemas.ts b/app/features/tournament-lfg/tournament-lfg-schemas.ts index edaac4d6c..56a69908d 100644 --- a/app/features/tournament-lfg/tournament-lfg-schemas.ts +++ b/app/features/tournament-lfg/tournament-lfg-schemas.ts @@ -1,5 +1,10 @@ import { z } from "zod"; -import { stringConstant, textAreaOptional, toggle } from "~/form/fields"; +import { + stringConstant, + textAreaOptional, + toggle, + userSearch, +} from "~/form/fields"; import { _action, id } from "~/utils/zod"; const noteFieldSchema = textAreaOptional({ @@ -12,6 +17,12 @@ export const addSubFormSchema = z.object({ message: noteFieldSchema, }); +export const addSubForUserFormSchema = z.object({ + ...addSubFormSchema.shape, + _action: stringConstant("ADD_SUB_FOR_USER"), + userId: userSearch({ label: "labels.user" }), +}); + const stayAsSubFieldSchema = toggle({ label: "labels.stayAsSub", bottomText: "bottomTexts.stayAsSub", @@ -60,6 +71,7 @@ export const lookingSchema = z.union([ userId: id, }), addSubFormSchema, + addSubForUserFormSchema, z.object({ _action: _action("DELETE_SUB"), userId: id, diff --git a/e2e/helpers/playwright-form.ts b/e2e/helpers/playwright-form.ts index 6c32df8c5..8a7338fda 100644 --- a/e2e/helpers/playwright-form.ts +++ b/e2e/helpers/playwright-form.ts @@ -199,7 +199,8 @@ export function createFormHelpers( async selectUser(name, userName) { const label = getLabel(String(name)); - const comboboxButton = page.getByLabel(label, { exact: true }); + // role + non-exact name: the trigger's accessible name is e.g. "User search User *" + const comboboxButton = page.getByRole("button", { name: label }); const searchInput = page.getByTestId("user-search-input"); const option = page.getByTestId("user-search-item").first(); diff --git a/e2e/pages/tournament/tournament-admin-page.ts b/e2e/pages/tournament/tournament-admin-page.ts index b6fe7ba9b..8e6b547e0 100644 --- a/e2e/pages/tournament/tournament-admin-page.ts +++ b/e2e/pages/tournament/tournament-admin-page.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import { addSubForUserFormSchema } from "~/features/tournament-lfg/tournament-lfg-schemas"; import { tournamentAdminPage } from "~/utils/urls"; import { modalClickConfirmButton, @@ -6,6 +7,7 @@ import { submit, waitForPOSTResponse, } from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; import { CalendarNewEventPage } from "../calendar/calendar-new-event-page"; import { TournamentAdminStaffPage } from "./tournament-admin-staff-page"; import { TournamentAdminStreamPage } from "./tournament-admin-stream-page"; @@ -15,11 +17,13 @@ import { TournamentNav } from "./tournament-nav"; export class TournamentAdminPage { private readonly page: Page; readonly nav; + readonly addSubForm; readonly locators; constructor(page: Page) { this.page = page; this.nav = new TournamentNav(page); + this.addSubForm = createFormHelpers(page, addSubForUserFormSchema); this.locators = { editEventInfoButton: page.getByTestId("edit-event-info-button"), searchInput: page.getByLabel("Search teams"), @@ -27,6 +31,10 @@ export class TournamentAdminPage { teamNames: page.getByTestId("team-name"), noSearchResultsText: page.getByText("No registrations match your search"), exportButton: page.getByRole("button", { name: "Export" }), + addSubButton: page.getByRole("button", { name: "Add sub" }), + addSubDialogHeading: page.getByRole("heading", { + name: "Add sub post on behalf of a user", + }), exportDialogHeading: page.getByRole("heading", { name: "Export participants", }), @@ -128,6 +136,10 @@ export class TournamentAdminPage { await this.locators.exportButton.click(); } + async openAddSubDialog() { + await this.locators.addSubButton.click(); + } + async downloadExport() { const downloadPromise = this.page.waitForEvent("download"); await this.locators.downloadButton.click(); diff --git a/e2e/pages/tournament/tournament-subs-page.ts b/e2e/pages/tournament/tournament-subs-page.ts new file mode 100644 index 000000000..cba107f26 --- /dev/null +++ b/e2e/pages/tournament/tournament-subs-page.ts @@ -0,0 +1,20 @@ +import type { Page } from "@playwright/test"; +import { tournamentSubsPage } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; + +/** `/to/:id/looking` — the subs list shown once registration has closed. */ +export class TournamentSubsPage { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async goto(tournamentId: number) { + await navigate({ page: this.page, url: tournamentSubsPage(tournamentId) }); + } + + subPostText(text: string) { + return this.page.getByText(text); + } +} diff --git a/e2e/tournament-admin.spec.ts b/e2e/tournament-admin.spec.ts index 0c7e11425..139dcaeb5 100644 --- a/e2e/tournament-admin.spec.ts +++ b/e2e/tournament-admin.spec.ts @@ -4,9 +4,16 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import type { Factories } from "./helpers/factories"; import { expect, impersonate, test } from "./helpers/playwright"; +import { + createTeams, + DOUBLE_ELIMINATION, + startedTournamentTimes, + teamSeeds, +} from "./helpers/tournament"; import { TournamentAdminAuditPage } from "./pages/tournament/tournament-admin-audit-page"; import { TournamentAdminPage } from "./pages/tournament/tournament-admin-page"; import { TournamentAdminRegistrationPage } from "./pages/tournament/tournament-admin-registration-page"; +import { TournamentSubsPage } from "./pages/tournament/tournament-subs-page"; const ROSTER_SIZE = 4; const CAPTAIN_DISCORD_ID = "1234567890123456789"; @@ -142,6 +149,87 @@ test.describe("Tournament admin team management", () => { expect(content).toContain("Alpha Squad"); }); + test("adds a sub post on behalf of a user", async ({ page, factories }) => { + // registration is closed (start time in the past) so the subs view is shown on the looking page + const tournament = await factories.TournamentFactory.create({ + authorId: NZAP_TEST_ID, + startTimes: [dateToDatabaseTimestamp(subDays(new Date(), 1))], + }); + await factories.UserFactory.create({ + discordName: "Subby Sam", + }); + + await impersonate(page, NZAP_TEST_ID); + + const admin = new TournamentAdminPage(page); + await admin.goto(tournament.id); + + await admin.openAddSubDialog(); + await expect(admin.locators.addSubDialogHeading).toBeVisible(); + + await admin.addSubForm.selectUser("userId", "Subby Sam"); + await admin.addSubForm.fill("message", "Can play backline"); + await admin.addSubForm.submit(); + + await expect(admin.locators.addSubDialogHeading).toHaveCount(0); + + const subs = new TournamentSubsPage(page); + await subs.goto(tournament.id); + await expect(subs.subPostText("Subby Sam")).toBeVisible(); + await expect(subs.subPostText("Can play backline")).toBeVisible(); + }); + + test("adds a sub post on behalf of a user whose team dropped out", async ({ + page, + factories, + }) => { + const tournament = await factories.TournamentFactory.create({ + authorId: NZAP_TEST_ID, + startTimes: startedTournamentTimes(), + bracketProgression: DOUBLE_ELIMINATION, + }); + const dropout = await factories.UserFactory.create({ + discordName: "Dropout Dana", + }); + const droppingRest = await factories.UserFactory.createMany( + ROSTER_SIZE - 1, + ); + await factories.TournamentTeamFactory.create( + { + tournamentId: tournament.id, + team: pickUpTeam("Recruiting Rays"), + memberUserIds: [dropout.id, ...droppingRest.map((user) => user.id)], + }, + // the team was recruiting on the LFG page before it dropped out + { isCheckedIn: true, isLooking: true }, + ); + // enough opponents that dropping out one team does not end every match + await createTeams(factories, tournament.id, teamSeeds(3)); + await factories.TournamentFactory.startBracket(tournament.id); + + await impersonate(page, NZAP_TEST_ID); + + const admin = new TournamentAdminPage(page); + await admin.goto(tournament.id); + + await admin.searchTeams("Recruiting Rays"); + await admin.dropOutTeam(0); + + await admin.openAddSubDialog(); + await expect(admin.locators.addSubDialogHeading).toBeVisible(); + + await admin.addSubForm.selectUser("userId", "Dropout Dana"); + await admin.addSubForm.fill("message", "Free to sub now"); + await admin.addSubForm.submit(); + + await expect(admin.locators.addSubDialogHeading).toHaveCount(0); + + const subs = new TournamentSubsPage(page); + await subs.goto(tournament.id); + await expect(subs.subPostText("Dropout Dana")).toBeVisible(); + await expect(subs.subPostText("Free to sub now")).toBeVisible(); + }); + test("filters the team list by name and by captain Discord id", async ({ page, factories,