Stay as sub toggle on the tournament lfg card

This commit is contained in:
Kalle
2026-09-06 07:15:55 +03:00
parent a78be51bff
commit d3f7011ad1
10 changed files with 203 additions and 42 deletions

View File

@@ -0,0 +1,65 @@
import { describe, expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import { SendouSwitch } from "./Switch";
function indicatorHeight(container: HTMLElement) {
const indicator = container.querySelector("label > div");
return indicator ? Number.parseFloat(getComputedStyle(indicator).height) : 0;
}
describe("SendouSwitch", () => {
test("toggles when clicked", async () => {
const screen = await render(
<SendouSwitch aria-label="Stay as sub" data-testid="switch" />,
);
await expect.element(screen.getByRole("switch")).not.toBeChecked();
await screen.getByTestId("switch").click();
await expect.element(screen.getByRole("switch")).toBeChecked();
});
test("reports the new state to onChange", async () => {
const onChange = vi.fn();
const screen = await render(
<SendouSwitch
aria-label="Stay as sub"
data-testid="switch"
defaultSelected
onChange={onChange}
/>,
);
await screen.getByTestId("switch").click();
expect(onChange).toHaveBeenCalledWith(false);
});
test("stays at the state given by isSelected when controlled", async () => {
const screen = await render(
<SendouSwitch
aria-label="Stay as sub"
data-testid="switch"
isSelected={false}
onChange={vi.fn()}
/>,
);
await screen.getByTestId("switch").click();
await expect.element(screen.getByRole("switch")).not.toBeChecked();
});
test("renders a smaller indicator with size small", async () => {
const defaultSize = await render(<SendouSwitch aria-label="Default" />);
const smallSize = await render(
<SendouSwitch aria-label="Small" size="small" />,
);
expect(indicatorHeight(smallSize.container)).toBeLessThan(
indicatorHeight(defaultSize.container),
);
});
});

View File

@@ -70,3 +70,9 @@
overflow: hidden;
white-space: nowrap;
}
.small {
--height: var(--selector-size-xs);
font-size: var(--font-2xs);
}

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import type * as React from "react";
import styles from "./Switch.module.css";
@@ -7,6 +8,7 @@ interface SendouSwitchProps {
defaultSelected?: boolean;
onChange?: (isSelected: boolean) => void;
isDisabled?: boolean;
size?: "small";
"aria-label"?: string;
"data-testid"?: string;
children?: React.ReactNode;
@@ -18,12 +20,16 @@ export function SendouSwitch({
defaultSelected,
onChange,
isDisabled,
size,
"aria-label": ariaLabel,
"data-testid": testId,
children,
}: SendouSwitchProps) {
return (
<label className={styles.root} data-testid={testId}>
<label
className={clsx(styles.root, { [styles.small]: size === "small" })}
data-testid={testId}
>
<input
id={id}
type="checkbox"

View File

@@ -605,6 +605,12 @@ function SwitchSection({ id }: { id: string }) {
</SendouSwitch>
</ComponentRow>
<ComponentRow label="Small">
<SendouSwitch size="small" isSelected={isOn} onChange={setIsOn}>
Toggle me
</SendouSwitch>
</ComponentRow>
<ComponentRow label="Without Label">
<SendouSwitch aria-label="Toggle without label" />
</ComponentRow>

View File

@@ -247,9 +247,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
value: data.note ?? null,
});
break;
}
case "SET_STAY_AS_SUB": {
const ownGroup = await findOwnGroup();
if (!ownGroup) return null;
await TournamentLFGRepository.updateOwnStayAsSub({
teamId: ownGroup.id,
value: data.stayAsSub ?? false,
value: data.stayAsSub,
});
break;

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import { Mic, Star, Trash, Volume2, VolumeX } from "lucide-react";
import { Edit, Mic, Star, Trash, Volume2, VolumeX } from "lucide-react";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
@@ -8,6 +8,7 @@ import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouSwitch } from "~/components/elements/Switch";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, WeaponImage } from "~/components/Image";
import { NoteAvatar } from "~/components/NoteAvatar";
@@ -19,6 +20,7 @@ import {
useUserCardData,
} from "~/features/user-card/components/UserCard";
import { SendouForm } from "~/form/SendouForm";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import { languagesUnified } from "~/modules/i18n/config";
@@ -103,8 +105,8 @@ export function LFGGroupCard({
))}
</div>
{isOwnGroup ? (
<LFGTeamNote
key={`${group.note ?? ""}-${currentMember?.isStayAsSub ?? false}`}
<LFGOwnGroupControls
key={group.note ?? ""}
note={group.note}
editable={group.usersRole === "OWNER"}
isStayAsSub={currentMember?.isStayAsSub ?? false}
@@ -249,7 +251,7 @@ function LFGGroupMemberRow({
);
}
function LFGTeamNote({
function LFGOwnGroupControls({
note,
editable,
isStayAsSub,
@@ -260,60 +262,67 @@ function LFGTeamNote({
isStayAsSub: boolean;
memberCount: number;
}) {
const { t } = useTranslation(["common", "q"]);
const { t } = useTranslation(["q"]);
const [editing, setEditing] = React.useState(false);
if (editing) {
return (
<LFGEditGroupForm
note={note}
isStayAsSub={isStayAsSub}
memberCount={memberCount}
stopEditing={() => setEditing(false)}
/>
<LFGEditGroupForm note={note} stopEditing={() => setEditing(false)} />
);
}
if (note) {
return (
<div className="text-lighter text-center text-xs mt-1">
{note}{" "}
return (
<div className="stack sm">
{note ? (
<div className="text-lighter text-center text-xs">{note}</div>
) : null}
<div className="stack horizontal sm items-center">
{memberCount === 1 ? (
<LFGStayAsSubSwitch isStayAsSub={isStayAsSub} />
) : null}
{editable ? (
<SendouButton
size="miniscule"
variant="minimal"
variant="outlined"
icon={<Edit />}
onClick={() => setEditing(true)}
className="mt-2 ml-auto"
className="ml-auto"
>
{t("q:looking.groups.editNote")}
{note
? t("q:looking.groups.editNote")
: t("q:looking.groups.addNote")}
</SendouButton>
) : null}
</div>
);
}
</div>
);
}
if (!editable) return null;
/** Changes the sub preference in place so the group keeps its spot in the list. */
function LFGStayAsSubSwitch({ isStayAsSub }: { isStayAsSub: boolean }) {
const { t } = useTranslation(["forms"]);
const { submit, fetcher } = useActionSubmit(lookingSchema, {
encType: "application/json",
});
const submitted = fetcher.json as { stayAsSub: boolean } | undefined;
return (
<SendouButton
variant="minimal"
size="miniscule"
onClick={() => setEditing(true)}
<SendouSwitch
size="small"
isSelected={submitted?.stayAsSub ?? isStayAsSub}
onChange={(stayAsSub) => submit("SET_STAY_AS_SUB", { stayAsSub })}
>
{t("q:looking.groups.addNote")}
</SendouButton>
{t("forms:labels.stayAsSub")}
</SendouSwitch>
);
}
function LFGEditGroupForm({
note,
isStayAsSub,
memberCount,
stopEditing,
}: {
note: string | null;
isStayAsSub: boolean;
memberCount: number;
stopEditing: () => void;
}) {
const { t } = useTranslation(["common"]);
@@ -321,7 +330,7 @@ function LFGEditGroupForm({
return (
<SendouForm
schema={updateGroupFormSchema}
defaultValues={{ note: note ?? undefined, stayAsSub: isStayAsSub }}
defaultValues={{ note: note ?? undefined }}
submitButtonText={t("common:actions.save")}
secondarySubmit={
<SendouButton
@@ -333,12 +342,7 @@ function LFGEditGroupForm({
</SendouButton>
}
>
{({ FormField }) => (
<>
<FormField name="note" />
{memberCount === 1 ? <FormField name="stayAsSub" /> : null}
</>
)}
{({ FormField }) => <FormField name="note" />}
</SendouForm>
);
}

View File

@@ -37,7 +37,6 @@ export const joinQueueFormSchema = v.object({
export const updateGroupFormSchema = v.object({
_action: stringConstant("UPDATE_GROUP"),
note: noteFieldSchema,
stayAsSub: stayAsSubFieldSchema,
});
export const lookingSchema = v.union([
@@ -67,6 +66,10 @@ export const lookingSchema = v.union([
userId: id,
}),
updateGroupFormSchema,
v.object({
_action: _action("SET_STAY_AS_SUB"),
stayAsSub: v.boolean(),
}),
v.object({
_action: _action("LEAVE_GROUP"),
}),

View File

@@ -0,0 +1,5 @@
---
navItem: calendar
type: feature
---
Toggle "Stay as sub" without having to leave the tournament LFG list first

View File

@@ -0,0 +1,31 @@
import type { Page } from "@playwright/test";
import { joinQueueFormSchema } from "~/features/tournament-lfg/tournament-lfg-schemas";
import { tournamentSubsPage } from "~/utils/urls";
import { navigate, waitForPOSTResponse } from "../../helpers/playwright";
import { createFormHelpers } from "../../helpers/playwright-form";
/** `/to/:id/looking` — the groups view shown while registration is still open. */
export class TournamentLookingPage {
private readonly page: Page;
readonly joinQueueForm;
readonly locators;
constructor(page: Page) {
this.page = page;
this.joinQueueForm = createFormHelpers(page, joinQueueFormSchema);
this.locators = {
stayAsSubSwitch: page.getByRole("switch", { name: "Stay as sub" }),
};
}
goto(tournamentId: number) {
return navigate({ page: this.page, url: tournamentSubsPage(tournamentId) });
}
toggleStayAsSub() {
return waitForPOSTResponse(this.page, () =>
// the switch input itself is visually hidden behind its indicator
this.locators.stayAsSubSwitch.click({ force: true }),
);
}
}

View File

@@ -1,7 +1,8 @@
import { subDays } from "date-fns";
import { addDays, subDays } from "date-fns";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { TournamentLookingPage } from "./pages/tournament/tournament-looking-page";
import { TournamentSubsPage } from "./pages/tournament/tournament-subs-page";
const SUB_NAME = "Subby Sam";
@@ -44,4 +45,32 @@ test.describe("Tournament LFG", () => {
await expect(subs.locators.noPostsText).toBeVisible();
await expect(subs.locators.addPostButton).toBeVisible();
});
test("player changes their sub preference without leaving the queue", async ({
page,
factories,
}) => {
// registration is open (start time in the future) so the groups view is shown
const tournament = await factories.TournamentFactory.create({
authorId: ADMIN_ID,
startTimes: [dateToDatabaseTimestamp(addDays(new Date(), 1))],
});
const sub = await factories.UserFactory.create({
discordName: SUB_NAME,
});
await impersonate(page, sub.id);
const looking = new TournamentLookingPage(page);
await looking.goto(tournament.id);
await looking.joinQueueForm.submit();
await expect(looking.locators.stayAsSubSwitch).not.toBeChecked();
await looking.toggleStayAsSub();
await looking.goto(tournament.id);
await expect(looking.locators.stayAsSubSwitch).toBeChecked();
});
});