Add tournament subs on tournament admin page

This commit is contained in:
Kalle
2026-08-01 21:19:48 +03:00
parent 1d143cb8b3
commit a8a877fa02
8 changed files with 229 additions and 2 deletions

View File

@@ -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
</LinkButton>
) : null}
{tournament.canAddNewSubPostAsOrganizer ? <AddSubButton /> : null}
</div>
<Input
className={styles.searchInput}
@@ -145,6 +150,43 @@ export default function TournamentAdminTeamsPage() {
);
}
function AddSubButton() {
const tournament = useTournament();
const [dialogOpen, setDialogOpen] = React.useState(false);
return (
<>
<SendouButton
size="small"
variant="outlined"
icon={<Plus />}
onPress={() => setDialogOpen(true)}
>
Add sub
</SendouButton>
{dialogOpen ? (
<SendouDialog
heading="Add sub post on behalf of a user"
onClose={() => setDialogOpen(false)}
>
<SendouForm
schema={addSubForUserFormSchema}
action={tournamentSubsPage(tournament.ctx.id)}
onSuccess={() => setDialogOpen(false)}
>
{({ FormField }) => (
<>
<FormField name="userId" />
<FormField name="message" />
</>
)}
</SendouForm>
</SendouDialog>
) : null}
</>
);
}
function TeamRow({
team,
maxRosterSize,

View File

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

View File

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

View File

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

View File

@@ -199,7 +199,8 @@ export function createFormHelpers<T extends z.ZodRawShape>(
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();

View File

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

View File

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

View File

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