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,