1
0
Fork 0

Compare commits

...

3 commits

Author SHA1 Message Date
34b5300904 test(preferences): add unit tests for PreferencesForm component
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-19 20:41:09 -04:00
a9d9ba50ea feat(preferences): add version overlay toggle and redesign settings UI
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-19 20:40:56 -04:00
d57b0cbc22 feat(ui): add shadcn switch and radio-group components
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-19 20:40:46 -04:00
9 changed files with 398 additions and 125 deletions

View file

@ -203,6 +203,7 @@ export async function updatePreferences(input: {
mutedAll: boolean; mutedAll: boolean;
mutedTypes: string[]; mutedTypes: string[];
showCallsign: boolean; showCallsign: boolean;
showVersionOverlay: boolean;
leadOrActual: "lead" | "actual"; leadOrActual: "lead" | "actual";
discordEnabled: boolean; discordEnabled: boolean;
discordMutedTypes: string[]; discordMutedTypes: string[];
@ -226,6 +227,7 @@ export async function updatePreferences(input: {
notifications: { mutedAll: input.mutedAll, mutedTypes }, notifications: { mutedAll: input.mutedAll, mutedTypes },
display: { display: {
showCallsign: input.showCallsign, showCallsign: input.showCallsign,
showVersionOverlay: input.showVersionOverlay,
leadOrActual: input.leadOrActual, leadOrActual: input.leadOrActual,
}, },
discord: { discord: {

View file

@ -91,6 +91,7 @@ export default async function AccountPage() {
mutedAll={notifications?.mutedAll ?? false} mutedAll={notifications?.mutedAll ?? false}
mutedTypes={notifications?.mutedTypes ?? []} mutedTypes={notifications?.mutedTypes ?? []}
showCallsign={fullUser.preferences?.display?.showCallsign ?? false} showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
showVersionOverlay={fullUser.preferences?.display?.showVersionOverlay ?? true}
leadOrActual={fullUser.preferences?.display?.leadOrActual ?? "lead"} leadOrActual={fullUser.preferences?.display?.leadOrActual ?? "lead"}
discordEnabled={discord?.enabled ?? false} discordEnabled={discord?.enabled ?? false}
discordMutedTypes={discord?.mutedTypes ?? []} discordMutedTypes={discord?.mutedTypes ?? []}

View file

@ -12,11 +12,13 @@ import { hasIntelligenceQualification } from "@/utils/access-control/hasIntellig
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts"; import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime"; import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
import VersionOverlay from "@/components/static/VersionOverlay";
import { LandingPage } from "@/components/frontend/LandingPage"; import { LandingPage } from "@/components/frontend/LandingPage";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { CommandPalette } from "@/components/command-palette/CommandPalette"; import { CommandPalette } from "@/components/command-palette/CommandPalette";
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext"; import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider"; import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
import { isSuperuser } from "@/utils/access-control/hasPermission";
// Skip static prerendering of this layout (and all pages under (frontend)/). // Skip static prerendering of this layout (and all pages under (frontend)/).
// The layout calls `getPayload()` at render time to resolve the current user, // The layout calls `getPayload()` at render time to resolve the current user,
@ -33,9 +35,9 @@ export const metadata: Metadata = {
{ {
url: "/favicon.webp", url: "/favicon.webp",
type: "image/webp", type: "image/webp",
rel: "icon" rel: "icon",
} },
] ],
}; };
export default async function RootLayout(props: { children: React.ReactNode }) { export default async function RootLayout(props: { children: React.ReactNode }) {
@ -129,6 +131,9 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
) : ( ) : (
<LandingPage /> <LandingPage />
)} )}
{(!user || user.preferences?.display?.showVersionOverlay !== false) && (
<VersionOverlay canSeeCommit={isSuperuser(payload, user)} />
)}
{user && <GameTickRealtime />} {user && <GameTickRealtime />}
{isLogistics && <ShipmentToasts />} {isLogistics && <ShipmentToasts />}
<Toaster /> <Toaster />

View file

@ -275,7 +275,13 @@ export const Users: CollectionConfig = {
{ label: "Actual", value: "actual" }, { label: "Actual", value: "actual" },
], ],
defaultValue: "lead", defaultValue: "lead",
} },
{
name: "showVersionOverlay",
label: "Show version overlay at top of page",
type: "checkbox",
defaultValue: true,
},
], ],
}, },
{ {

View file

@ -1,77 +1,92 @@
"use client"; "use client";
import { useState } from "react"; import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2Icon } from "lucide-react"; import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
import { updatePreferences } from "@/app/(frontend)/account/actions"; import { updatePreferences } from "@/app/(frontend)/account/actions";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
interface PreferencesFormProps { interface PreferencesFormProps {
mutedAll: boolean; readonly mutedAll: boolean;
mutedTypes: string[]; readonly mutedTypes: readonly string[];
showCallsign: boolean; readonly showCallsign: boolean;
leadOrActual: "lead" | "actual"; readonly showVersionOverlay: boolean;
discordEnabled: boolean; readonly leadOrActual: "lead" | "actual";
discordMutedTypes: string[]; readonly discordEnabled: boolean;
readonly discordMutedTypes: readonly string[];
} }
export function PreferencesForm({ export function PreferencesForm({
mutedAll: initialMutedAll, mutedAll: initialMutedAll,
mutedTypes: initialMutedTypes, mutedTypes: initialMutedTypes,
showCallsign: initialShowCallsign, showCallsign: initialShowCallsign,
showVersionOverlay: initialShowVersionOverlay,
leadOrActual: initialLeadOrActual, leadOrActual: initialLeadOrActual,
discordEnabled: initialDiscordEnabled, discordEnabled: initialDiscordEnabled,
discordMutedTypes: initialDiscordMutedTypes, discordMutedTypes: initialDiscordMutedTypes,
}: PreferencesFormProps) { }: PreferencesFormProps) {
const router = useRouter(); const router = useRouter();
const [mutedAll, setMutedAll] = useState(initialMutedAll); const [mutedAll, setMutedAll] = useState(initialMutedAll);
const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes); const [mutedTypes, setMutedTypes] = useState<string[]>(() => [...initialMutedTypes]);
const [showCallsign, setShowCallsign] = useState(initialShowCallsign); const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
const [showVersionOverlay, setShowVersionOverlay] = useState(initialShowVersionOverlay);
const [leadOrActual, setLeadOrActual] = useState<"lead" | "actual">(initialLeadOrActual); const [leadOrActual, setLeadOrActual] = useState<"lead" | "actual">(initialLeadOrActual);
const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled); const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled);
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(initialDiscordMutedTypes); const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(() => [
...initialDiscordMutedTypes,
]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const toggleType = (value: string, checked: boolean) => { const toggleType = (value: string, checked: boolean) => {
setMutedTypes((prev) => setMutedTypes((previous) =>
checked ? [...new Set([...prev, value])] : prev.filter((t) => t !== value), checked ? [...new Set([...previous, value])] : previous.filter((type) => type !== value),
); );
}; };
const toggleDiscordType = (value: string, checked: boolean) => { const toggleDiscordType = (value: string, checked: boolean) => {
setDiscordMutedTypes((prev) => setDiscordMutedTypes((previous) =>
checked ? [...new Set([...prev, value])] : prev.filter((t) => t !== value), checked ? [...new Set([...previous, value])] : previous.filter((type) => type !== value),
); );
}; };
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
e.preventDefault(); event.preventDefault();
setLoading(true); setLoading(true);
setError(null); setError(null);
setSuccess(null); setSuccess(null);
try { try {
const res = await updatePreferences({ const result = await updatePreferences({
mutedAll, mutedAll,
mutedTypes, mutedTypes,
showCallsign, showCallsign,
showVersionOverlay,
leadOrActual, leadOrActual,
discordEnabled, discordEnabled,
discordMutedTypes, discordMutedTypes,
}); });
if (!res.success) {
setError(res.error ?? "Failed to save preferences."); if (!result.success) {
setError(result.error ?? "Could not save preferences.");
return; return;
} }
setSuccess("Preferences saved.");
setSuccess("Preferences updated.");
router.refresh(); router.refresh();
} catch { } catch (caughtError) {
setError("Something went wrong. Please try again."); setError(
caughtError instanceof Error
? caughtError.message
: "Could not save preferences. Please try again.",
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -79,101 +94,190 @@ export function PreferencesForm({
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6"> <form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div className="flex flex-col gap-3"> <Card>
<h3 className="text-sm font-medium">Notifications</h3> <CardHeader>
<label className="flex items-center gap-2 text-sm"> <CardTitle>Display</CardTitle>
<Checkbox checked={mutedAll} onCheckedChange={(c) => setMutedAll(c === true)} /> <CardDescription>Choose what appears in the app shell and roster views.</CardDescription>
Mute all in-app notifications </CardHeader>
</label> <CardContent className="flex flex-col gap-4">
<div <Label
className={ htmlFor="showVersionOverlay"
mutedAll className="flex items-center justify-between gap-4 text-sm"
? "flex flex-col gap-3 pl-6 opacity-50 pointer-events-none"
: "flex flex-col gap-3 pl-6"
}
> >
{MUTEABLE_NOTIFICATION_TYPES.map((t) => ( <span className="flex min-w-0 flex-col gap-1">
<label key={t.value} className="flex items-center gap-2 text-sm"> <span>Show version overlay</span>
<Checkbox <span className="text-sm font-normal text-muted-foreground">
checked={mutedTypes.includes(t.value)} Display the current build version at the top of every page.
onCheckedChange={(c) => toggleType(t.value, c === true)} </span>
</span>
<Switch
id="showVersionOverlay"
checked={showVersionOverlay}
onCheckedChange={setShowVersionOverlay}
aria-label="Show version overlay"
/> />
{t.label}
</label>
))}
</div>
</div>
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Display</h3>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={showCallsign} onCheckedChange={(c) => setShowCallsign(c === true)} />
Show callsign (username) instead of display name
</label>
<div className="flex flex-col gap-2 pl-6">
<Label className="text-sm text-muted-foreground">
Roster leader label
</Label> </Label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="leadOrActual"
value="lead"
checked={leadOrActual === "lead"}
onChange={() => setLeadOrActual("lead")}
className="accent-primary"
/>
Lead
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="leadOrActual"
value="actual"
checked={leadOrActual === "actual"}
onChange={() => setLeadOrActual("actual")}
className="accent-primary"
/>
Actual
</label>
</div>
</div>
</div>
<div className="flex flex-col gap-3"> <Label htmlFor="showCallsign" className="flex items-center justify-between gap-4 text-sm">
<h3 className="text-sm font-medium">Discord</h3> <span className="flex min-w-0 flex-col gap-1">
<label className="flex items-center gap-2 text-sm"> <span>Show callsign instead of display name</span>
<Checkbox <span className="text-sm font-normal text-muted-foreground">
checked={discordEnabled} Use your username in the sidebar and app shell.
onCheckedChange={(c) => setDiscordEnabled(c === true)} </span>
</span>
<Switch
id="showCallsign"
checked={showCallsign}
onCheckedChange={setShowCallsign}
aria-label="Show callsign instead of display name"
/> />
Discord notifications (DM me about app events) </Label>
</label>
<div <div className="flex flex-col gap-2">
className={ <div className="flex flex-col gap-1">
discordEnabled <span className="text-sm font-medium">Roster leader label</span>
? "flex flex-col gap-3 pl-6" <span className="text-sm text-muted-foreground">
: "flex flex-col gap-3 pl-6 opacity-50 pointer-events-none" Choose the label used when reviewing the unit roster.
} </span>
</div>
<RadioGroup
value={leadOrActual}
onValueChange={(value) => {
if (value === "lead" || value === "actual") setLeadOrActual(value);
}}
className="flex flex-wrap gap-4"
aria-label="Roster leader label"
> >
{MUTEABLE_NOTIFICATION_TYPES.map((t) => ( <div className="flex items-center gap-2">
<label key={t.value} className="flex items-center gap-2 text-sm"> <RadioGroupItem id="leadOrActual-lead" value="lead" />
<Checkbox <Label htmlFor="leadOrActual-lead">Lead</Label>
checked={discordMutedTypes.includes(t.value)}
onCheckedChange={(c) => toggleDiscordType(t.value, c === true)}
/>
Mute {t.label.toLowerCase()}
</label>
))}
</div> </div>
<div className="flex items-center gap-2">
<RadioGroupItem id="leadOrActual-actual" value="actual" />
<Label htmlFor="leadOrActual-actual">Actual</Label>
</div> </div>
</RadioGroup>
</div>
</CardContent>
</Card>
{error && <p className="text-sm text-destructive">{error}</p>} <Card>
{success && <p className="text-sm text-emerald-500">{success}</p>} <CardHeader>
<div> <CardTitle>Notifications</CardTitle>
<CardDescription>Control the in-app alerts that appear in your inbox.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Label htmlFor="mutedAll" className="flex items-center justify-between gap-4 text-sm">
<span className="flex min-w-0 flex-col gap-1">
<span>Mute all in-app notifications</span>
<span className="text-sm font-normal text-muted-foreground">
Pause every in-app notification listed below.
</span>
</span>
<Switch
id="mutedAll"
checked={mutedAll}
onCheckedChange={setMutedAll}
aria-label="Mute all in-app notifications"
/>
</Label>
<div
className={cn("flex flex-col gap-3 pl-6", mutedAll && "opacity-50 pointer-events-none")}
aria-disabled={mutedAll}
>
{MUTEABLE_NOTIFICATION_TYPES.map((notificationType) => {
const id = `notification-${notificationType.value.replace(":", "-")}`;
return (
<Label
key={notificationType.value}
htmlFor={id}
className="flex items-center justify-between gap-4 text-sm font-normal"
>
<span>{notificationType.label}</span>
<Switch
id={id}
checked={mutedTypes.includes(notificationType.value)}
onCheckedChange={(checked) => toggleType(notificationType.value, checked)}
disabled={mutedAll}
aria-label={`Mute ${notificationType.label.toLowerCase()}`}
/>
</Label>
);
})}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Discord</CardTitle>
<CardDescription>Choose whether the bot can send you direct messages.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Label
htmlFor="discordEnabled"
className="flex items-center justify-between gap-4 text-sm"
>
<span className="flex min-w-0 flex-col gap-1">
<span>Enable Discord notifications</span>
<span className="text-sm font-normal text-muted-foreground">
Receive direct messages about app events.
</span>
</span>
<Switch
id="discordEnabled"
checked={discordEnabled}
onCheckedChange={setDiscordEnabled}
aria-label="Enable Discord notifications"
/>
</Label>
<div
className={cn(
"flex flex-col gap-3 pl-6",
!discordEnabled && "opacity-50 pointer-events-none",
)}
aria-disabled={!discordEnabled}
>
{MUTEABLE_NOTIFICATION_TYPES.map((notificationType) => {
const id = `discord-${notificationType.value.replace(":", "-")}`;
return (
<Label
key={notificationType.value}
htmlFor={id}
className="flex items-center justify-between gap-4 text-sm font-normal"
>
<span>Mute {notificationType.label.toLowerCase()}</span>
<Switch
id={id}
checked={discordMutedTypes.includes(notificationType.value)}
onCheckedChange={(checked) =>
toggleDiscordType(notificationType.value, checked)
}
disabled={!discordEnabled}
aria-label={`Mute ${notificationType.label.toLowerCase()}`}
/>
</Label>
);
})}
</div>
</CardContent>
</Card>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{success && (
<p aria-live="polite" className="text-sm text-emerald-500">
{success}
</p>
)}
<div className="sticky bottom-0 bg-card p-6 w-full -m-6">
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading}>
{loading && <Loader2Icon className="size-4 animate-spin" />} {loading && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{loading ? "Saving..." : "Save preferences"} {loading ? "Saving..." : "Save preferences"}
</Button> </Button>
</div> </div>

View file

@ -2,6 +2,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { VersionInfo } from "@/lib/versionInfo"; import type { VersionInfo } from "@/lib/versionInfo";
import { getPayload } from "payload";
import config from "@payload-config";
import { User } from "@/payload-types";
const EMPTY: VersionInfo = { const EMPTY: VersionInfo = {
version: null, version: null,
@ -30,7 +33,7 @@ function formatCommitDate(iso: string | null): string | null {
}); });
} }
const VersionOverlay = () => { const VersionOverlay = ({ canSeeCommit }: { canSeeCommit: boolean }) => {
const [info, setInfo] = useState<VersionInfo | null>(null); const [info, setInfo] = useState<VersionInfo | null>(null);
useEffect(() => { useEffect(() => {
@ -59,13 +62,15 @@ const VersionOverlay = () => {
return ( return (
<div className="fixed top-0 z-9999 w-full flex justify-center"> <div className="fixed top-0 z-9999 w-full flex justify-center">
<div className="text-white flex flex-col items-center justify-center bg-red-950 w-fit px-2 rounded-b group overflow-hidden"> <div className="text-white flex flex-col items-center justify-center bg-accent w-fit px-2 rounded-b group overflow-hidden opacity-33 hover:opacity-100">
<span className="text-center text-sm pointer-events-none">version {versionLabel}</span> <span className="text-center text-xs pointer-events-none text-amber-500">
version {versionLabel}
</span>
{showSecondary && ( {showSecondary && (
<span className="text-center text-xs h-0 group-hover:h-full pointer-events-none transition-all duration-100 ease-in-out"> <span className="text-center text-[10px] gap-0 leading-3.5 h-0 group-hover:h-full pointer-events-none transition-all duration-100 ease-in-out">
{lastUpdated && <>last updated {lastUpdated}</>} {lastUpdated && <>last updated {lastUpdated}</>}
{lastUpdated && commitInfo && <br />} {canSeeCommit && lastUpdated && commitInfo && <br />}
{commitInfo && <>{commitInfo}</>} {canSeeCommit && commitInfo && <span className="opacity-50">{commitInfo}</span>}
</span> </span>
)} )}
</div> </div>

View file

@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import { CircleIcon } from "lucide-react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View file

@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View file

@ -0,0 +1,70 @@
import { createElement } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PreferencesForm } from "@/components/frontend/account/PreferencesForm";
import { updatePreferences } from "@/app/(frontend)/account/actions";
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));
vi.mock("@/app/(frontend)/account/actions", () => ({
updatePreferences: vi.fn(),
}));
class ResizeObserverMock implements ResizeObserver {
observe(_target: Element, _options?: ResizeObserverOptions): void {}
unobserve(_target: Element): void {}
disconnect(): void {}
}
globalThis.ResizeObserver = ResizeObserverMock;
const mockedUpdatePreferences = vi.mocked(updatePreferences);
describe("PreferencesForm", () => {
beforeEach(() => {
mockedUpdatePreferences.mockReset();
mockedUpdatePreferences.mockResolvedValue({ success: true });
});
it("submits all display, in-app, and Discord preferences", async () => {
render(
createElement(PreferencesForm, {
mutedAll: false,
mutedTypes: ["market:offer"],
showCallsign: false,
showVersionOverlay: true,
leadOrActual: "lead",
discordEnabled: false,
discordMutedTypes: [],
}),
);
fireEvent.click(screen.getByRole("switch", { name: "Show version overlay" }));
fireEvent.click(screen.getByRole("switch", { name: "Show callsign instead of display name" }));
fireEvent.click(screen.getByRole("switch", { name: "Mute all in-app notifications" }));
fireEvent.click(screen.getByRole("radio", { name: "Actual" }));
fireEvent.click(screen.getByRole("switch", { name: "Enable Discord notifications" }));
const discordMarketOffer = document.getElementById("discord-market-offer");
if (!(discordMarketOffer instanceof HTMLElement)) {
throw new Error("Discord market-offer switch was not rendered.");
}
fireEvent.click(discordMarketOffer);
fireEvent.click(screen.getByRole("button", { name: "Save preferences" }));
await waitFor(() => {
expect(mockedUpdatePreferences).toHaveBeenCalledWith({
mutedAll: true,
mutedTypes: ["market:offer"],
showCallsign: true,
showVersionOverlay: false,
leadOrActual: "actual",
discordEnabled: true,
discordMutedTypes: ["market:offer"],
});
});
});
});