mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 05:38:09 -05:00
Add tournament subs on tournament admin page
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
20
e2e/pages/tournament/tournament-subs-page.ts
Normal file
20
e2e/pages/tournament/tournament-subs-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user