mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 20:26:08 -05:00
Stay as sub toggle on the tournament lfg card
This commit is contained in:
65
app/components/elements/Switch.browser.test.tsx
Normal file
65
app/components/elements/Switch.browser.test.tsx
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -70,3 +70,9 @@
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.small {
|
||||
--height: var(--selector-size-xs);
|
||||
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
|
||||
5
changelog/2026-09-05-lfg-stay-as-sub-toggle.md
Normal file
5
changelog/2026-09-05-lfg-stay-as-sub-toggle.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: calendar
|
||||
type: feature
|
||||
---
|
||||
Toggle "Stay as sub" without having to leave the tournament LFG list first
|
||||
31
e2e/pages/tournament/tournament-looking-page.ts
Normal file
31
e2e/pages/tournament/tournament-looking-page.ts
Normal 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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user