Merge branch 'main' into test-refactor

This commit is contained in:
Kalle
2026-07-30 13:19:06 +03:00
260 changed files with 4413 additions and 3421 deletions

View File

@@ -1,13 +1,8 @@
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { Image } from "~/components/Image";
import { InfoPopover } from "~/components/InfoPopover";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
import { addFriendCodeSchema } from "~/features/sendouq/q-schemas";
import { SendouForm } from "~/form/SendouForm";
import { navIconUrl, SENDOUQ_PAGE } from "~/utils/urls";
const FC_INFO_IMAGE_URL = navIconUrl("fc-info");
@@ -17,60 +12,39 @@ export function FriendCodeInput({
}: {
friendCode?: string | null;
}) {
const fetcher = useFetcher();
const { t } = useTranslation(["common"]);
const id = React.useId();
if (friendCode) {
return <div className="font-bold text-center">SW-{friendCode}</div>;
}
return (
<fetcher.Form method="post" action={SENDOUQ_PAGE}>
<input type="hidden" name="revalidateRoot" value="true" />
<div
className={clsx("stack sm horizontal items-end", {
"justify-center": friendCode,
})}
>
<div>
{!friendCode ? (
<div className="stack horizontal xs items-center">
<Label htmlFor={id}>{t("common:fc.title")}</Label>
<InfoPopover tiny>
<div className="stack sm">
<div className="text-xs font-bold">
{t("common:fc.whereToFind")}
</div>
<Image
path={FC_INFO_IMAGE_URL}
alt={t("common:fc.whereToFind")}
width={320}
/>
<SendouForm
schema={addFriendCodeSchema}
action={SENDOUQ_PAGE}
revalidateRoot
submitButtonText={t("common:actions.save")}
>
{({ FormField }) => (
<div className="stack sm">
<FormField name="friendCode" />
<div className="stack horizontal sm items-center text-lighter text-xs">
{t("common:fc.onceSetStaffOnly")}
<InfoPopover tiny>
<div className="stack sm">
<div className="text-xs font-bold">
{t("common:fc.whereToFind")}
</div>
</InfoPopover>
</div>
) : null}
{friendCode ? (
<div className="font-bold">SW-{friendCode}</div>
) : (
<Input
leftAddon="SW-"
id={id}
name="friendCode"
pattern={FRIEND_CODE_REGEXP_PATTERN}
placeholder="1234-5678-9012"
required
/>
)}
<Image
path={FC_INFO_IMAGE_URL}
alt={t("common:fc.whereToFind")}
width={320}
/>
</div>
</InfoPopover>
</div>
</div>
{!friendCode ? (
<SubmitButton _action="ADD_FRIEND_CODE" state={fetcher.state}>
{t("common:actions.save")}
</SubmitButton>
) : null}
</div>
{!friendCode ? (
<div className="text-lighter text-xs mt-2">
{t("common:fc.onceSetStaffOnly")}
</div>
) : null}
</fetcher.Form>
)}
</SendouForm>
);
}

View File

@@ -15,7 +15,7 @@ import {
import * as React from "react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Link, useLocation } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
@@ -27,6 +27,7 @@ import {
EVENTS_PAGE,
FRIENDS_PAGE,
navIconUrl,
SENDOU_INK_BASE_URL,
SETTINGS_PAGE,
SUPPORT_PAGE,
userPage,
@@ -44,6 +45,7 @@ import {
import { navItems } from "./layout/nav-items";
import styles from "./MobileNav.module.css";
import { NotificationDot } from "./NotificationDot";
import { ShareUrlButton } from "./ShareUrlButton";
import { StreamListItems } from "./StreamListItems";
type SidebarData = RootLoaderData["sidebar"] | undefined;
@@ -346,6 +348,7 @@ function MenuOverlay({
}) {
const { t } = useTranslation(["front", "common"]);
const user = useUser();
const location = useLocation();
return (
<ModalOverlay
@@ -377,6 +380,11 @@ function MenuOverlay({
{t("common:pages.support")}
</LinkButton>
) : null}
<ShareUrlButton
variant="minimal"
shape="square"
url={`${SENDOU_INK_BASE_URL}${location.pathname}${location.search}`}
/>
<button
type="button"
className={styles.panelCloseButton}

View File

@@ -0,0 +1,44 @@
import { Share2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { CopyToClipboardPopover } from "./CopyToClipboardPopover";
export function ShareUrlButton({
url,
...buttonProps
}: { url: string } & React.ComponentProps<typeof SendouButton>) {
const { t } = useTranslation(["common"]);
const canNativeShare =
typeof navigator !== "undefined" && typeof navigator.share === "function";
if (canNativeShare) {
return (
<SendouButton
variant="outlined"
size="small"
shape="circle"
icon={<Share2 />}
onPress={() => navigator.share({ url })}
aria-label={t("common:actions.share")}
{...buttonProps}
/>
);
}
return (
<CopyToClipboardPopover
url={url}
trigger={
<SendouButton
variant="outlined"
size="small"
shape="circle"
icon={<Share2 />}
aria-label={t("common:actions.share")}
{...buttonProps}
/>
}
/>
);
}

View File

@@ -268,6 +268,7 @@
.listLinkSubtitleRow {
display: flex;
align-items: center;
gap: var(--s-1-5);
width: 100%;
color: var(--color-text-high);
}

View File

@@ -16,6 +16,7 @@ interface StageSelectProps<Clearable extends boolean | undefined = undefined> {
clearable?: Clearable;
testId?: string;
isRequired?: boolean;
isDisabled?: boolean;
}
export function StageSelect<Clearable extends boolean | undefined = undefined>({
@@ -26,6 +27,7 @@ export function StageSelect<Clearable extends boolean | undefined = undefined>({
clearable,
testId = "stage-select",
isRequired,
isDisabled,
}: StageSelectProps<Clearable>) {
const { t } = useTranslation(["common", "game-misc"]);
const items = useStageItems();
@@ -54,6 +56,7 @@ export function StageSelect<Clearable extends boolean | undefined = undefined>({
clearable={clearable}
data-testid={testId}
isRequired={isRequired}
isDisabled={isDisabled}
>
{({ id, name }) => (
<SendouSelectItem key={id} id={id} textValue={name}>

View File

@@ -1,21 +1,43 @@
import { LocaleTime } from "~/components/LocaleTime";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
month: "numeric",
year: "2-digit",
day: "numeric",
hour: "numeric",
minute: "numeric",
};
const CLASS_NAME = "text-lighter font-semi-bold";
interface MatchBannerStartedAtProps {
time: Date;
/** When given, the time the match ended, shown as a range together with the start time */
endTime?: Date | null;
}
export function MatchBannerStartedAt({ time }: MatchBannerStartedAtProps) {
export function MatchBannerStartedAt({
time,
endTime,
}: MatchBannerStartedAtProps) {
if (endTime) {
return (
<LocaleTimeRange
from={time}
to={endTime}
options={FORMAT_OPTIONS}
className={CLASS_NAME}
inline
/>
);
}
return (
<LocaleTime
date={time}
options={{
month: "numeric",
year: "2-digit",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
className="text-lighter font-semi-bold"
options={FORMAT_OPTIONS}
className={CLASS_NAME}
inline
/>
);

View File

@@ -185,7 +185,7 @@ function TimelineHeader({
) : null}
{isOngoing ? (
<span className={styles.headerScoreLive}>
{t("q:match.timeline.live")}
{t("q:match.timeline.ongoing")}
</span>
) : null}
</div>

View File

@@ -1,20 +1,20 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { requireUser } from "~/features/auth/core/user.server";
import { refreshBannedCache } from "~/features/ban/core/banned.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import { requireRole } from "~/modules/permissions/guards.server";
import {
errorToast,
notFoundIfNullish,
parseRequestPayload,
successToast,
} from "~/utils/remix.server";
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { _action, actualNumber, friendCode } from "~/utils/zod";
import { normalizeFriendCode } from "~/utils/zod";
import { adminActionSchema } from "../admin-schemas";
import {
sendUserBannedWebhook,
sendUserUnbannedWebhook,
@@ -22,10 +22,16 @@ import {
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: adminActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const user = requireUser();
let message: string;
@@ -35,8 +41,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
try {
const errorMessage = await AdminRepository.migrate({
oldUserId: data["old-user"],
newUserId: data["new-user"],
oldUserId: data.oldUser,
newUserId: data.newUser,
});
if (errorMessage) {
@@ -75,8 +81,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await AdminRepository.forcePatron({
id: data.user,
patronStartedAt: new Date(),
patronTier: data.patronTier,
patronExpiresAt: new Date(data.patronExpiresAt),
patronTier: Number(data.patronTier),
patronExpiresAt: data.patronExpiresAt,
});
message = "Patron status updated";
@@ -123,7 +129,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const bannedUser = notFoundIfNullish(
await UserRepository.findLeanById(data.user),
);
const banExpiresAt = data.duration ? new Date(data.duration) : null;
const banExpiresAt = data.expiresAt ?? null;
await AdminRepository.banUser({
bannedReason: data.reason ?? null,
@@ -170,7 +176,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
requireRole("STAFF");
await UserRepository.insertFriendCode({
friendCode: data.friendCode,
friendCode: normalizeFriendCode(data.friendCode),
submitterUserId: user.id,
userId: data.user,
});
@@ -193,56 +199,3 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return successToast(message);
};
export const adminActionSchema = z.union([
z.object({
_action: _action("MIGRATE"),
"old-user": z.preprocess(actualNumber, z.number().positive()),
"new-user": z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("REFRESH"),
}),
z.object({
_action: _action("FORCE_PATRON"),
user: z.preprocess(actualNumber, z.number().positive()),
patronTier: z.preprocess(actualNumber, z.number()),
patronExpiresAt: z.string(),
}),
z.object({
_action: _action("VIDEO_ADDER"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("TOURNAMENT_ORGANIZER"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("ARTIST"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("LINK_PLAYER"),
user: z.preprocess(actualNumber, z.number().positive()),
playerId: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("BAN_USER"),
user: z.preprocess(actualNumber, z.number().positive()),
reason: z.string().nullish(),
duration: z.string().nullish(),
}),
z.object({
_action: _action("UNBAN_USER"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("UPDATE_FRIEND_CODE"),
friendCode,
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("API_ACCESS"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
]);

View File

@@ -1,3 +1,5 @@
export const BAN_REASON_MAX_LENGTH = 200;
export const ADMIN_DISCORD_ID = "79237403620945920";
export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274;

View File

@@ -1,26 +1,132 @@
import { z } from "zod";
import { friendCodeField } from "~/features/sendouq/q-schemas";
import {
datetimeRequired,
datetime,
datetimeOptional,
image,
numberField,
select,
stringConstant,
textFieldRequired,
textField,
textFieldOptional,
userSearch,
} from "~/form/fields";
import { friendCode, id } from "~/utils/zod";
import { BAN_REASON_MAX_LENGTH } from "./admin-constants";
export const adminActionSearchParamsSchema = z.object({
friendCode,
});
const userField = userSearch({ label: "labels.user" });
export const friendCodeSearchSchema = z.object({
friendCode: friendCodeField,
});
export const migrateUserSchema = z.object({
_action: stringConstant("MIGRATE"),
oldUser: userSearch({ label: "labels.adminOldUser" }),
newUser: userSearch({
label: "labels.adminNewUser",
bottomText: "bottomTexts.adminMigrateNewUser",
}),
});
export const linkPlayerSchema = z.object({
_action: stringConstant("LINK_PLAYER"),
user: userField,
playerId: numberField({ label: "labels.adminPlayerId", min: 1 }),
});
export const giveArtistSchema = z.object({
_action: stringConstant("ARTIST"),
user: userField,
});
export const giveVideoAdderSchema = z.object({
_action: stringConstant("VIDEO_ADDER"),
user: userField,
});
export const giveTournamentOrganizerSchema = z.object({
_action: stringConstant("TOURNAMENT_ORGANIZER"),
user: userField,
});
export const giveApiAccessSchema = z.object({
_action: stringConstant("API_ACCESS"),
user: userField,
});
export const updateFriendCodeSchema = z.object({
_action: stringConstant("UPDATE_FRIEND_CODE"),
user: userField,
friendCode: friendCodeField,
});
export const forcePatronSchema = z.object({
_action: stringConstant("FORCE_PATRON"),
user: userField,
patronTier: select({
label: "labels.patronTier",
items: [
{ value: "1", label: "options.patronTier.1" },
{ value: "2", label: "options.patronTier.2" },
{ value: "3", label: "options.patronTier.3" },
],
}),
patronExpiresAt: datetime({ label: "labels.patronExpiresAt" }),
});
export const banUserSchema = z.object({
_action: stringConstant("BAN_USER"),
user: userField,
expiresAt: datetimeOptional({
label: "labels.banUserExpiresAt",
bottomText: "bottomTexts.banUserExpiresAtHelp",
min: () => new Date(),
minMessage: "errors.dateInPast",
}),
reason: textFieldOptional({
label: "labels.reason",
maxLength: BAN_REASON_MAX_LENGTH,
}),
});
export const unbanUserSchema = z.object({
_action: stringConstant("UNBAN_USER"),
user: userField,
});
export const refreshPlusTiersSchema = z.object({
_action: stringConstant("REFRESH"),
});
export const adminActionSchema = z.union([
migrateUserSchema,
linkPlayerSchema,
giveArtistSchema,
giveVideoAdderSchema,
giveTournamentOrganizerSchema,
giveApiAccessSchema,
updateFriendCodeSchema,
forcePatronSchema,
banUserSchema,
unbanUserSchema,
refreshPlusTiersSchema,
]);
export const createExternalStreamSchema = z.object({
_action: stringConstant("CREATE"),
name: textFieldRequired({ label: "labels.name", maxLength: 64 }),
url: textFieldRequired({
name: textField({ label: "labels.name", maxLength: 64 }),
url: textField({
label: "labels.link",
maxLength: 200,
validate: "url",
}),
avatar: image({ label: "labels.logo", autoValidate: true }),
startTime: datetimeRequired({ label: "labels.startTime" }),
startTime: datetime({ label: "labels.startTime" }),
});
const deleteExternalStreamSchema = z.object({

View File

@@ -10,10 +10,13 @@ import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/le
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
import type { adminActionSchema } from "../actions/admin.server";
import type { adminActionSchema } from "../admin-schemas";
import { action } from "./admin";
const adminAction = wrappedAction<typeof adminActionSchema>({ action });
const adminAction = wrappedAction<typeof adminActionSchema>({
action,
isJsonSubmission: true,
});
const users = UserFactory.pool();
@@ -260,8 +263,8 @@ const migrateUserAction = () =>
adminAction(
{
_action: "MIGRATE",
"old-user": users.id(1),
"new-user": users.id(2),
oldUser: users.id(1),
newUser: users.id(2),
},
{ user: "admin" },
);

View File

@@ -1,4 +1,3 @@
import { Search } from "lucide-react";
import * as React from "react";
import type { MetaFunction } from "react-router";
import {
@@ -6,7 +5,6 @@ import {
Link,
useFetcher,
useLoaderData,
useNavigation,
useSearchParams,
} from "react-router";
import { Avatar } from "~/components/Avatar";
@@ -19,11 +17,9 @@ import {
SendouTabs,
} from "~/components/elements/Tabs";
import { UserSearch } from "~/components/elements/UserSearch";
import { FormMessage } from "~/components/FormMessage";
import { Input } from "~/components/Input";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
import { SendouForm } from "~/form/SendouForm";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
import {
@@ -33,6 +29,20 @@ import {
userPage,
} from "~/utils/urls";
import { action } from "../actions/admin.server";
import {
banUserSchema,
forcePatronSchema,
friendCodeSearchSchema,
giveApiAccessSchema,
giveArtistSchema,
giveTournamentOrganizerSchema,
giveVideoAdderSchema,
linkPlayerSchema,
migrateUserSchema,
refreshPlusTiersSchema,
unbanUserSchema,
updateFriendCodeSchema,
} from "../admin-schemas";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls";
import { loader } from "../loaders/admin.server";
@@ -59,7 +69,7 @@ export default function AdminPage() {
}
return (
<Main>
<Main halfWidth>
<SendouTabs>
<SendouTabList>
<SendouTab id="actions">Actions</SendouTab>
@@ -79,28 +89,17 @@ export default function AdminPage() {
function FriendCodeLookUp() {
const data = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const [friendCode, setFriendCode] = React.useState(
searchParams.get("friendCode") ?? "",
);
const fetcher = useFetcher();
return (
<div>
<div className="stack md horizontal justify-center">
<Input
placeholder="1234-5678-9101"
name="friendCode"
value={friendCode}
onChange={(e) => setFriendCode(e.target.value)}
/>
<SubmitButton
state={fetcher.state}
icon={<Search />}
onPress={() => setSearchParams({ friendCode })}
>
Search
</SubmitButton>
</div>
<div className="stack lg">
<SendouForm
schema={friendCodeSearchSchema}
defaultValues={{ friendCode: searchParams.get("friendCode") ?? "" }}
submitButtonText="Search"
onApply={({ friendCode }) => setSearchParams({ friendCode })}
>
{({ FormField }) => <FormField name="friendCode" />}
</SendouForm>
<div className="stack lg">
{data.friendCodeSearchUsers?.map((user) => (
<Link
@@ -175,272 +174,157 @@ function Impersonate() {
}
function MigrateUser() {
const [oldUserId, setOldUserId] = React.useState<number>();
const [newUserId, setNewUserId] = React.useState<number>();
const navigation = useNavigation();
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2>Migrate user data</h2>
<div className="stack horizontal md">
<div className="flex-same-size">
<UserSearch
label="Old user"
name="old-user"
onChange={(newUser) => setOldUserId(newUser?.id)}
/>
</div>
<div className="flex-same-size">
<UserSearch
label="New user"
name="new-user"
onChange={(newUser) => setNewUserId(newUser?.id)}
/>
</div>
</div>
<div className="stack horizontal md">
<SubmitButton
type="submit"
isDisabled={!oldUserId || !newUserId || navigation.state !== "idle"}
_action="MIGRATE"
state={fetcher.state}
>
Migrate
</SubmitButton>
</div>
<FormMessage type="info">
Note: data on "New user" will be deleted (e.g. builds)
</FormMessage>
</fetcher.Form>
<SendouForm
schema={migrateUserSchema}
title="Migrate user data"
submitButtonText="Migrate"
>
{({ FormField }) => (
<>
<FormField name="oldUser" />
<FormField name="newUser" />
</>
)}
</SendouForm>
);
}
function LinkPlayer() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2>Link player</h2>
<div className="stack horizontal md">
<div className="flex-same-size">
<UserSearch label="User" name="user" />
</div>
<div className="flex-same-size">
<label>Player ID</label>
<input type="number" name="playerId" />
</div>
</div>
<div className="stack horizontal md">
<SubmitButton type="submit" _action="LINK_PLAYER" state={fetcher.state}>
Link player
</SubmitButton>
</div>
</fetcher.Form>
<SendouForm
schema={linkPlayerSchema}
title="Link player"
submitButtonText="Link player"
>
{({ FormField }) => (
<>
<FormField name="user" />
<FormField name="playerId" />
</>
)}
</SendouForm>
);
}
function GiveArtist() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2>Add as artist</h2>
<div className="stack horizontal md">
<UserSearch label="User" name="user" />
</div>
<div className="stack horizontal md">
<SubmitButton type="submit" _action="ARTIST" state={fetcher.state}>
Add as artist
</SubmitButton>
</div>
</fetcher.Form>
<SendouForm
schema={giveArtistSchema}
title="Add as artist"
submitButtonText="Add as artist"
>
{({ FormField }) => <FormField name="user" />}
</SendouForm>
);
}
function GiveVideoAdder() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2>Give video adder</h2>
<div className="stack horizontal md">
<UserSearch label="User" name="user" />
</div>
<div className="stack horizontal md">
<SubmitButton type="submit" _action="VIDEO_ADDER" state={fetcher.state}>
Add as video adder
</SubmitButton>
</div>
</fetcher.Form>
<SendouForm
schema={giveVideoAdderSchema}
title="Give video adder"
submitButtonText="Add as video adder"
>
{({ FormField }) => <FormField name="user" />}
</SendouForm>
);
}
function GiveTournamentOrganizer() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2>Give tournament organizer</h2>
<UserSearch label="User" name="user" />
<div className="stack horizontal md">
<SubmitButton
type="submit"
_action="TOURNAMENT_ORGANIZER"
state={fetcher.state}
>
Add as tournament organizer
</SubmitButton>
</div>
</fetcher.Form>
<SendouForm
schema={giveTournamentOrganizerSchema}
title="Give tournament organizer"
submitButtonText="Add as tournament organizer"
>
{({ FormField }) => <FormField name="user" />}
</SendouForm>
);
}
function GiveApiAccess() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2>Give API access</h2>
<UserSearch label="User" name="user" />
<div className="stack horizontal md">
<SubmitButton type="submit" _action="API_ACCESS" state={fetcher.state}>
Grant API access
</SubmitButton>
</div>
</fetcher.Form>
<SendouForm
schema={giveApiAccessSchema}
title="Give API access"
submitButtonText="Grant API access"
>
{({ FormField }) => <FormField name="user" />}
</SendouForm>
);
}
function UpdateFriendCode() {
const fetcher = useFetcher();
const id = React.useId();
return (
<fetcher.Form className="stack md" method="post">
<h2>Update friend code</h2>
<div className="stack horizontal md">
<div className="flex-same-size">
<UserSearch label="User" name="user" />
</div>
<div className="flex-same-size">
<label htmlFor={id}>Friend code</label>
<Input
leftAddon="SW-"
id={id}
name="friendCode"
pattern={FRIEND_CODE_REGEXP_PATTERN}
placeholder="1234-5678-9012"
/>
</div>
</div>
<div className="stack horizontal md">
<SubmitButton
type="submit"
_action="UPDATE_FRIEND_CODE"
state={fetcher.state}
>
Submit
</SubmitButton>
</div>
</fetcher.Form>
<SendouForm schema={updateFriendCodeSchema} title="Update friend code">
{({ FormField }) => (
<>
<FormField name="user" />
<FormField name="friendCode" />
</>
)}
</SendouForm>
);
}
function ForcePatron() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2>Force patron</h2>
<div className="stack horizontal md">
<div className="flex-same-size">
<UserSearch label="User" name="user" />
</div>
<div className="flex-same-size">
<label>Tier</label>
<select name="patronTier">
<option value="1">Support</option>
<option value="2">Supporter</option>
<option value="3">Supporter+</option>
</select>
</div>
<div className="flex-same-size">
<label>Patron till</label>
<input name="patronExpiresAt" type="date" />
</div>
</div>
<div className="stack horizontal md">
<SubmitButton
type="submit"
_action="FORCE_PATRON"
state={fetcher.state}
>
Save
</SubmitButton>
</div>
</fetcher.Form>
<SendouForm
schema={forcePatronSchema}
title="Force patron"
submitButtonText="Save"
>
{({ FormField }) => (
<>
<FormField name="user" />
<FormField name="patronTier" />
<FormField name="patronExpiresAt" />
</>
)}
</SendouForm>
);
}
function BanUser() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2 className="text-warning">Ban user</h2>
<div className="stack horizontal md">
<div className="flex-same-size">
<UserSearch label="User" name="user" />
</div>
<div className="flex-same-size">
<label>Banned till</label>
<input name="duration" type="datetime-local" />
</div>
<div className="flex-same-size">
<label>Reason</label>
<input name="reason" type="text" />
</div>
</div>
<div className="stack horizontal md">
<SubmitButton type="submit" _action="BAN_USER" state={fetcher.state}>
Save
</SubmitButton>
</div>
</fetcher.Form>
<SendouForm
schema={banUserSchema}
title={<span className="text-warning">Ban user</span>}
submitButtonText="Save"
>
{({ FormField }) => (
<>
<FormField name="user" />
<FormField name="expiresAt" />
<FormField name="reason" />
</>
)}
</SendouForm>
);
}
function UnbanUser() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2 className="text-warning">Unban user</h2>
<UserSearch label="User" name="user" />
<div className="stack horizontal md">
<SubmitButton type="submit" _action="UNBAN_USER" state={fetcher.state}>
Save
</SubmitButton>
</div>
</fetcher.Form>
<SendouForm
schema={unbanUserSchema}
title={<span className="text-warning">Unban user</span>}
submitButtonText="Save"
>
{({ FormField }) => <FormField name="user" />}
</SendouForm>
);
}
function RefreshPlusTiers() {
const fetcher = useFetcher();
return (
<fetcher.Form method="post">
<h2>Refresh Plus Tiers</h2>
<SubmitButton type="submit" _action="REFRESH" state={fetcher.state}>
Refresh
</SubmitButton>
</fetcher.Form>
<SendouForm
schema={refreshPlusTiersSchema}
title="Refresh Plus Tiers"
submitButtonText="Refresh"
>
{null}
</SendouForm>
);
}

View File

@@ -1,62 +1,57 @@
import type { FileUpload } from "@remix-run/form-data-parser";
import { nanoid } from "nanoid";
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import * as R from "remeda";
import * as ArtRepository from "~/features/art/ArtRepository.server";
import { requireUser } from "~/features/auth/core/user.server";
import { uploadStreamToS3 } from "~/features/img-upload/s3.server";
import { ALLOWED_IMAGE_EXTENSIONS } from "~/features/img-upload/upload-constants";
import { notify } from "~/features/notifications/core/notify.server";
import { parseFormData } from "~/form/parse.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import {
errorToastIfFalsy,
parseFormData,
parseRequestPayload,
safeParseMultipartFormData,
} from "~/utils/remix.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { toDBBoolean } from "~/utils/sql";
import { userArtPage } from "~/utils/urls";
import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants";
import { editArtSchema, newArtSchema } from "../art-schemas.server";
import { ART_FORM_MAX_BODY_BYTES } from "../art-image";
import { uploadArtImage } from "../art-image.server";
import { artFormSchema } from "../art-schemas";
export const action: ActionFunction = async ({ request, url }) => {
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
requireRole("ARTIST");
const searchParams = url.searchParams;
const artIdRaw = searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY);
const result = await parseFormData({
request,
schema: artFormSchema,
maxBodyBytes: ART_FORM_MAX_BODY_BYTES,
});
// updating logic
if (artIdRaw) {
const artId = Number(artIdRaw);
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const linkedUsers = R.unique(
data.linkedUsers.filter((userId) => typeof userId === "number"),
);
if (data.artId) {
const userArts = await ArtRepository.findArtsByUserId(user.id, {
includeTagged: false,
});
const existingArt = userArts.find((art) => art.id === artId);
const existingArt = userArts.find((art) => art.id === data.artId);
errorToastIfFalsy(existingArt, "Art author is someone else");
const data = await parseRequestPayload({
request,
schema: editArtSchema,
});
const editedArtId = await ArtRepository.update(artId, {
const editedArtId = await ArtRepository.update(data.artId, {
description: data.description,
isShowcase: data.isShowcase,
linkedUsers: data.linkedUsers,
isShowcase: toDBBoolean(data.isShowcase),
linkedUsers,
tags: data.tags,
});
const existingLinkedUserIds =
existingArt.linkedUsers?.map((u) => u.id) ?? [];
const newLinkedUsers = data.linkedUsers.filter(
(userId) => !existingLinkedUserIds.includes(userId),
);
notify({
userIds: newLinkedUsers,
userIds: R.difference(linkedUsers, existingLinkedUserIds),
notification: {
type: "TAGGED_TO_ART",
meta: {
@@ -67,61 +62,18 @@ export const action: ActionFunction = async ({ request, url }) => {
},
});
} else {
const preDecidedFilename = `art-${nanoid()}-${Date.now()}`;
const uploadHandler = async (fileUpload: FileUpload) => {
if (
fileUpload.fieldName === "img" ||
fileUpload.fieldName === "smallImg"
) {
const ending = fileUpload.name.split(".").pop()?.toLowerCase();
invariant(
ending && ending !== fileUpload.name,
`File missing extension: "${fileUpload.name}"`,
);
invariant(
ALLOWED_IMAGE_EXTENSIONS.includes(ending),
`Invalid file extension: "${ending}"`,
);
const newFilename = `${preDecidedFilename}${fileUpload.fieldName === "smallImg" ? "-small" : ""}.${ending}`;
const uploadedFileLocation = await uploadStreamToS3(
fileUpload.stream(),
newFilename,
);
return uploadedFileLocation;
}
return null;
};
const formData = await safeParseMultipartFormData(
request,
// 5MB
{ maxFileSize: 5 * 1024 * 1024 },
uploadHandler,
);
const imgSrc = formData.get("img") as string | null;
invariant(imgSrc);
const urlParts = imgSrc.split("/");
const fileName = urlParts[urlParts.length - 1];
invariant(fileName);
const data = await parseFormData({
formData,
schema: newArtSchema,
});
errorToastIfFalsy(data.img?.type === "NEW", "Art image is missing");
const addedArt = await ArtRepository.insert({
description: data.description,
url: fileName,
url: await uploadArtImage(data.img),
validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null,
linkedUsers: data.linkedUsers,
linkedUsers,
tags: data.tags,
});
notify({
userIds: data.linkedUsers,
userIds: linkedUsers,
notification: {
type: "TAGGED_TO_ART",
meta: {

View File

@@ -0,0 +1,43 @@
import { basename } from "node:path";
import { Readable } from "node:stream";
import { dataUrlToImageBuffer } from "~/features/img-upload/image-bytes.server";
import { uploadStreamToS3 } from "~/features/img-upload/s3.server";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import { previewUrl } from "./art-utils";
const ALLOWED_ART_IMAGE_EXTENSIONS = ["png", "jpeg", "webp"] as const;
/**
* Uploads both assets a newly submitted art needs — the full image and its thumbnail, following
* the `<name>-small.<ext>` convention {@link previewUrl} resolves — and returns the full image's
* file name to store on the art's image row.
*/
export async function uploadArtImage({
dataUrl,
thumbnailDataUrl,
}: {
dataUrl: string;
thumbnailDataUrl: string;
}): Promise<string> {
const image = dataUrlToImageBuffer(dataUrl, ALLOWED_ART_IMAGE_EXTENSIONS);
const thumbnail = dataUrlToImageBuffer(
thumbnailDataUrl,
ALLOWED_ART_IMAGE_EXTENSIONS,
);
invariant(
image.extension === thumbnail.extension,
"Art image and its thumbnail are of a different format",
);
const fileName = `art-${Date.now()}-${shortNanoid()}.${image.extension}`;
const [uploadedLocation] = await Promise.all([
uploadStreamToS3(Readable.from(image.buffer), fileName),
uploadStreamToS3(Readable.from(thumbnail.buffer), previewUrl(fileName)),
]);
invariant(uploadedLocation, "Art image upload failed");
return basename(uploadedLocation);
}

View File

@@ -0,0 +1,81 @@
import { z } from "zod";
/**
* Allowed prefixes for an art data URL. Unlike the generic `image()` form field, art keeps the
* uploaded image's own format instead of normalizing everything to webp.
*/
const ART_IMAGE_DATA_URL_PREFIX_REGEX = /^data:image\/(png|jpeg|webp);base64,/;
/**
* Largest full-size art image accepted, decoded. Art is submitted at its original resolution and
* png keeps its detail losslessly, so this needs the same headroom the multipart upload flow used
* to allow.
*/
const ART_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
/**
* Largest art thumbnail accepted, decoded. The client caps the thumbnail's width to
* `ART.THUMBNAIL_WIDTH`, which lands well below this.
*/
const ART_THUMBNAIL_MAX_BYTES = 2 * 1024 * 1024;
/**
* Ceiling for the whole art submit body. Fits both data URLs at their maximum plus the rest of the
* form's fields.
*/
export const ART_FORM_MAX_BODY_BYTES =
maxDataUrlLength(ART_IMAGE_MAX_BYTES + ART_THUMBNAIL_MAX_BYTES) + 100_000;
/** Error shown when a picked image doesn't fit within the limits above. */
export const ART_IMAGE_TOO_LARGE_ERROR = "forms:errors.imageTooLarge";
const artImageDataUrl = (maxBytes: number) =>
z
.string()
.max(maxDataUrlLength(maxBytes), ART_IMAGE_TOO_LARGE_ERROR)
.regex(ART_IMAGE_DATA_URL_PREFIX_REGEX);
/**
* JSON-serializable value of the art image form field. Art can't use the generic `image()` field:
* it preserves aspect ratio, keeps the original format and derives a separate thumbnail, which is
* why a `NEW` value carries two data URLs. An `EXISTING` value marks art whose image was already
* uploaded (only the preview url rides along, never bytes) — art images can't be swapped after
* upload.
*/
export const artImageValue = z
.union([
z.object({
type: z.literal("EXISTING"),
url: z.string(),
}),
z.object({
type: z.literal("NEW"),
dataUrl: artImageDataUrl(ART_IMAGE_MAX_BYTES),
thumbnailDataUrl: artImageDataUrl(ART_THUMBNAIL_MAX_BYTES),
}),
])
.nullable();
export type ArtImageValue = z.infer<typeof artImageValue>;
/**
* Does a freshly compressed art image exceed what the schema accepts? Lets the form field reject
* an oversized pick right away instead of only when the filled-out form is submitted.
*/
export function isArtImageTooLarge({
dataUrl,
thumbnailDataUrl,
}: {
dataUrl: string;
thumbnailDataUrl: string;
}) {
return (
dataUrl.length > maxDataUrlLength(ART_IMAGE_MAX_BYTES) ||
thumbnailDataUrl.length > maxDataUrlLength(ART_THUMBNAIL_MAX_BYTES)
);
}
/** Length a base64 data URL encoding `bytes` decoded bytes can reach, `data:` prefix included. */
function maxDataUrlLength(bytes: number) {
return Math.ceil(bytes / 3) * 4 + 32;
}

View File

@@ -1,47 +1,5 @@
import { z } from "zod";
import {
_action,
checkboxValueToDbBoolean,
dbBoolean,
falsyToNull,
id,
processMany,
removeDuplicates,
safeJSONParse,
} from "~/utils/zod";
import { ART } from "./art-constants";
const description = z.preprocess(
falsyToNull,
z.string().max(ART.DESCRIPTION_MAX_LENGTH).nullable(),
);
const linkedUsers = z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z.array(id).max(ART.LINKED_USERS_MAX_LENGTH),
);
const tags = z.preprocess(
safeJSONParse,
z
.array(
z.object({
name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(),
id: id.optional(),
}),
)
.max(ART.TAG_MAX_LENGTH),
);
export const newArtSchema = z.object({
description,
linkedUsers,
tags,
});
export const editArtSchema = z.object({
description,
linkedUsers,
tags,
isShowcase: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
});
import { _action, id } from "~/utils/zod";
const deleteArtSchema = z.object({
_action: _action("DELETE_ART"),

View File

@@ -0,0 +1,52 @@
import { z } from "zod";
import {
array,
customField,
idConstantOptional,
textAreaOptional,
toggle,
userSearchOptional,
} from "~/form/fields";
import { id } from "~/utils/zod";
import { ART } from "./art-constants";
import { artImageValue } from "./art-image";
const artTags = z
.array(
z.object({
name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(),
id: id.optional(),
}),
)
.max(ART.TAGS_MAX_LENGTH);
export const artFormSchema = z
.object({
artId: idConstantOptional(),
img: customField({ initialValue: null }, artImageValue),
description: textAreaOptional({
label: "labels.description",
maxLength: ART.DESCRIPTION_MAX_LENGTH,
}),
tags: customField({ initialValue: [] }, artTags),
linkedUsers: array({
label: "labels.linkedUsers",
bottomText: "bottomTexts.linkedUsers",
max: ART.LINKED_USERS_MAX_LENGTH,
field: userSearchOptional({ label: "labels.user" }),
}),
isShowcase: toggle({
label: "labels.showcase",
bottomText: "bottomTexts.showcase",
}),
})
.superRefine((data, ctx) => {
// existing art keeps its image, new art must bring one
if (!data.artId && data.img?.type !== "NEW") {
ctx.addIssue({
path: ["img"],
code: "custom",
message: "forms:errors.required",
});
}
});

View File

@@ -89,7 +89,7 @@ export function ArtGrid({
}
function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
const [imageLoaded, setImageLoaded] = React.useState(false);
const [imageSettled, setImageSettled] = React.useState(false);
const { formatter } = useDateTimeFormat({
year: "numeric",
month: "numeric",
@@ -107,11 +107,12 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
src={art.url}
loading="lazy"
className={styles.dialogImg}
onLoad={() => setImageLoaded(true)}
onLoad={() => setImageSettled(true)}
onError={() => setImageSettled(true)}
/>
{art.tags || art.linkedUsers ? (
<div
className={clsx(styles.tagsContainer, { invisible: !imageLoaded })}
className={clsx(styles.tagsContainer, { invisible: !imageSettled })}
>
{art.linkedUsers?.map((user) => (
<Link
@@ -136,7 +137,7 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
{art.description ? (
<div
className={clsx(styles.dialogDescription, {
invisible: !imageLoaded,
invisible: !imageSettled,
})}
>
{art.description}
@@ -167,7 +168,7 @@ function ImagePreview({
canEdit?: boolean;
showUploadDate?: boolean;
}) {
const [imageLoaded, setImageLoaded] = React.useState(false);
const [imageSettled, setImageSettled] = React.useState(false);
const { t } = useTranslation(["common", "art"]);
const formatDistanceToNow = useFormatDistanceToNow();
@@ -178,7 +179,8 @@ function ImagePreview({
src={previewUrl(art.url)}
loading="lazy"
onClick={onClick}
onLoad={() => setImageLoaded(true)}
onLoad={() => setImageSettled(true)}
onError={() => setImageSettled(true)}
className={enablePreview ? styles.thumbnail : undefined}
data-testid="art-image"
/>
@@ -190,7 +192,7 @@ function ImagePreview({
{img}
<div
className={clsx("stack horizontal justify-between mt-2", {
invisible: !imageLoaded,
invisible: !imageSettled,
})}
>
<LinkButton
@@ -235,7 +237,7 @@ function ImagePreview({
<Link
to={userArtPage(art.author, "MADE-BY")}
className={clsx("stack sm horizontal text-xs items-center mt-1", {
invisible: !imageLoaded,
invisible: !imageSettled,
})}
>
<Avatar user={art.author} size="xxs" />
@@ -244,7 +246,7 @@ function ImagePreview({
{uploadDateText ? (
<div
className={clsx("text-xs text-lighter", {
invisible: !imageLoaded,
invisible: !imageSettled,
})}
>
{uploadDateText}
@@ -279,7 +281,7 @@ function ImagePreview({
<div className="stack horizontal justify-between">
<div
className={clsx("stack sm horizontal text-xs items-center mt-1", {
invisible: !imageLoaded,
invisible: !imageSettled,
})}
>
<Avatar user={art.author} size="xxs" />
@@ -288,7 +290,7 @@ function ImagePreview({
{uploadDateText ? (
<div
className={clsx("text-xxs mt-1 text-lighter", {
invisible: !imageLoaded,
invisible: !imageSettled,
})}
>
{uploadDateText}

View File

@@ -0,0 +1,3 @@
.preview {
max-width: 100%;
}

View File

@@ -0,0 +1,115 @@
import clsx from "clsx";
import Compressor from "compressorjs";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { CustomFieldRenderProps } from "~/form";
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import { logger } from "~/utils/logger";
import { ART } from "../art-constants";
import {
ART_IMAGE_TOO_LARGE_ERROR,
type ArtImageValue,
isArtImageTooLarge,
} from "../art-image";
import { previewUrl } from "../art-utils";
import styles from "./ArtImageFormField.module.css";
type ArtImageFormFieldProps = Omit<
CustomFieldRenderProps<ArtImageValue>,
"name"
>;
/**
* Image picker for art. Produces both derived assets the art pipeline needs — the full image with
* its aspect ratio and format preserved, and a thumbnail — as base64 data URLs so they can ride
* along in `SendouForm`'s single JSON submit. Art of already uploaded art can't be swapped, so an
* `EXISTING` value renders as a plain preview.
*/
export function ArtImageFormField({
value,
onChange,
error,
}: ArtImageFormFieldProps) {
const id = React.useId();
const [tooLargeError, setTooLargeError] = React.useState<string>();
const { t } = useTranslation(["common"]);
if (value?.type === "EXISTING") {
return <img src={previewUrl(value.url)} alt="" />;
}
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setTooLargeError(undefined);
const uploadedFile = event.target.files?.[0];
if (!uploadedFile) {
onChange(null);
return;
}
try {
const [dataUrl, thumbnailDataUrl] = await Promise.all([
compressToDataUrl(uploadedFile, {}),
compressToDataUrl(uploadedFile, { maxWidth: ART.THUMBNAIL_WIDTH }),
]);
if (isArtImageTooLarge({ dataUrl, thumbnailDataUrl })) {
setTooLargeError(ART_IMAGE_TOO_LARGE_ERROR);
onChange(null);
return;
}
onChange({ type: "NEW", dataUrl, thumbnailDataUrl });
} catch (err) {
logger.error(err);
onChange(null);
}
};
return (
<FormFieldWrapper
id={id}
name="img"
label={t("common:upload.imageToUpload")}
error={tooLargeError ?? error}
required
>
<div className="stack sm items-start">
<input
id={id}
type="file"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={handleFileChange}
/>
{value ? (
<img
src={value.dataUrl}
alt=""
className={clsx(styles.preview, "rounded")}
/>
) : null}
</div>
</FormFieldWrapper>
);
}
function compressToDataUrl(
file: File,
options: Compressor.Options,
): Promise<string> {
return new Promise((resolve, reject) => {
new Compressor(file, {
...options,
success(result) {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () =>
reject(new Error("Failed to read compressed image"));
reader.readAsDataURL(result);
},
error: reject,
});
});
}

View File

@@ -0,0 +1,11 @@
/* the minimal button variant keeps the full field height and its own font size, which breaks
the baseline when it sits inline in a line of helper text */
.switcherButton {
height: auto;
font-size: inherit;
}
/* the new tag input is stretched to the field width, so the button must keep its own */
.addButton {
flex-shrink: 0;
}

View File

@@ -0,0 +1,152 @@
import { X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import type { CustomFieldRenderProps } from "~/form";
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import { ART } from "../art-constants";
import styles from "./ArtTagsFormField.module.css";
import { TagSelect } from "./TagSelect";
export type ArtTag = { name?: string; id?: number };
type ArtTagsFormFieldProps = Omit<CustomFieldRenderProps<ArtTag[]>, "name"> & {
/** All tags that exist in the database, selectable without creating a new one. */
existingTags: Array<{ id: number; name: string }>;
};
// note: not handling edge case where a tag was added by another user while this
// user was adding a new art with the same tag -> will crash
export function ArtTagsFormField({
value,
onChange,
error,
existingTags,
}: ArtTagsFormFieldProps) {
const id = React.useId();
const { t } = useTranslation(["art", "common"]);
const [creationMode, setCreationMode] = React.useState(false);
const [newTagValue, setNewTagValue] = React.useState("");
const handleAddNewTag = () => {
const normalizedNewTagValue = newTagValue
.trim()
// replace many whitespaces with one
.replace(/\s\s+/g, " ")
.toLowerCase();
if (
normalizedNewTagValue.length === 0 ||
normalizedNewTagValue.length > ART.TAG_MAX_LENGTH
) {
return;
}
const alreadyCreatedTag = existingTags.find(
(tag) => tag.name === normalizedNewTagValue,
);
if (alreadyCreatedTag) {
onChange([...value, alreadyCreatedTag]);
} else if (value.every((tag) => tag.name !== normalizedNewTagValue)) {
onChange([...value, { name: normalizedNewTagValue }]);
}
setNewTagValue("");
setCreationMode(false);
};
return (
<FormFieldWrapper
id={id}
name="tags"
label={t("art:forms.tags.title")}
error={error}
>
<div className="stack xs">
{value.length >= ART.TAGS_MAX_LENGTH ? (
<div className="text-sm text-warning">
{t("art:forms.tags.maxReached")}
</div>
) : creationMode ? (
<>
<div className="stack horizontal sm items-center">
<input
id={id}
placeholder={t("art:forms.tags.addNew.placeholder")}
value={newTagValue}
onChange={(e) => setNewTagValue(e.target.value)}
onKeyDown={(event) => {
if (event.code === "Enter") {
handleAddNewTag();
}
}}
/>
<SendouButton
size="small"
variant="outlined"
className={styles.addButton}
onPress={handleAddNewTag}
>
{t("common:actions.add")}
</SendouButton>
</div>
<div className="text-xs text-lighter">
<SendouButton
variant="minimal"
className={styles.switcherButton}
onPress={() => setCreationMode(false)}
>
{t("art:forms.tags.selectFromExisting")}
</SendouButton>
</div>
</>
) : (
<>
<TagSelect
// empty combobox on select
key={value.length}
tags={existingTags}
disabledKeys={value
.map((tag) => tag.id)
.filter((id) => id !== undefined)}
onSelectionChange={(tagName) =>
onChange([
...value,
existingTags.find((tag) => tag.name === tagName)!,
])
}
/>
<div className="stack horizontal xs items-center text-xs text-lighter">
{t("art:forms.tags.cantFindExisting")}
<SendouButton
variant="minimal"
className={styles.switcherButton}
onPress={() => setCreationMode(true)}
>
{t("art:forms.tags.addNew")}
</SendouButton>
</div>
</>
)}
{value.length > 0 ? (
<div className="text-sm stack sm flex-wrap horizontal">
{value.map((tag) => (
<div key={tag.name} className="stack horizontal xs items-center">
{tag.name}
<SendouButton
icon={<X />}
size="miniscule"
variant="minimal-destructive"
onPress={() =>
onChange(value.filter((it) => it.name !== tag.name))
}
/>
</div>
))}
</div>
) : null}
</div>
</FormFieldWrapper>
);
}

View File

@@ -1,27 +1,20 @@
import Compressor from "compressorjs";
import { X } from "lucide-react";
import { nanoid } from "nanoid";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Form, useFetcher, useLoaderData } from "react-router";
import { useLoaderData } from "react-router";
import { Alert } from "~/components/Alert";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import { UserSearch } from "~/components/elements/UserSearch";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import type { CustomFieldRenderProps } from "~/form";
import { SendouForm } from "~/form/SendouForm";
import { useHasRole } from "~/modules/permissions/hooks";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { artPage, navIconUrl } from "~/utils/urls";
import { metaTitle } from "../../../utils/remix";
import { action } from "../actions/art.new.server";
import { ART } from "../art-constants";
import { previewUrl } from "../art-utils";
import { TagSelect } from "../components/TagSelect";
import type { ArtImageValue } from "../art-image";
import { artFormSchema } from "../art-schemas";
import { ArtImageFormField } from "../components/ArtImageFormField";
import { type ArtTag, ArtTagsFormField } from "../components/ArtTagsFormField";
import { loader } from "../loaders/art.new.server";
export { action, loader };
@@ -43,31 +36,9 @@ export const meta: MetaFunction = () => {
export default function NewArtPage() {
const data = useLoaderData<typeof loader>();
const [img, setImg] = React.useState<File | null>(null);
const [smallImg, setSmallImg] = React.useState<File | null>(null);
const { t } = useTranslation(["common", "art"]);
const ref = React.useRef<HTMLFormElement>(null);
const fetcher = useFetcher();
const { t } = useTranslation(["art"]);
const isArtist = useHasRole("ARTIST");
const handleSubmit = () => {
const formData = new FormData(ref.current!);
if (img) formData.append("img", img, img.name);
if (smallImg) formData.append("smallImg", smallImg, smallImg.name);
fetcher.submit(formData, {
encType: "multipart/form-data",
method: "post",
});
};
const submitButtonDisabled = () => {
if (fetcher.state !== "idle") return true;
return (!img || !smallImg) && !data.art;
};
if (!isArtist) {
return (
<Main className="stack items-center">
@@ -76,321 +47,51 @@ export default function NewArtPage() {
);
}
const isCurrentlyShowcase = Boolean(data.art?.isShowcase);
return (
<Main halfWidth>
<Form ref={ref} className="stack md">
<FormMessage type="info">{t("art:forms.caveats")}</FormMessage>
<ImageUpload img={img} setImg={setImg} setSmallImg={setSmallImg} />
<Description />
<Tags />
<LinkedUsers />
{data.art ? <ShowcaseToggle /> : null}
<div>
<SendouButton
onPress={handleSubmit}
isDisabled={submitButtonDisabled()}
>
{t("common:actions.save")}
</SendouButton>
</div>
</Form>
<SendouForm
schema={artFormSchema}
defaultValues={{
artId: data.art?.id,
img: data.art ? { type: "EXISTING", url: data.art.url } : null,
description: data.art?.description ?? "",
tags: data.art?.tags ?? [],
linkedUsers: data.art?.linkedUsers?.map((user) => user.id) ?? [],
isShowcase: isCurrentlyShowcase,
}}
>
{({ FormField }) => (
<>
<FormMessage type="info">{t("art:forms.caveats")}</FormMessage>
<FormField name="img">
{({ value, onChange, error }: CustomFieldRenderProps) => (
<ArtImageFormField
value={value as ArtImageValue}
onChange={onChange as (value: ArtImageValue) => void}
error={error}
/>
)}
</FormField>
<FormField name="description" />
<FormField name="tags">
{({ value, onChange, error }: CustomFieldRenderProps) => (
<ArtTagsFormField
value={value as ArtTag[]}
onChange={onChange as (value: ArtTag[]) => void}
error={error}
existingTags={data.tags}
/>
)}
</FormField>
<FormField name="linkedUsers" />
{data.art ? (
<FormField name="isShowcase" disabled={isCurrentlyShowcase} />
) : null}
</>
)}
</SendouForm>
</Main>
);
}
function ImageUpload({
img,
setImg,
setSmallImg,
}: {
img: File | null;
setImg: (file: File | null) => void;
setSmallImg: (file: File | null) => void;
}) {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common"]);
const id = React.useId();
if (data.art) {
return <img src={previewUrl(data.art.url)} alt="" />;
}
return (
<div>
<label htmlFor={id}>{t("common:upload.imageToUpload")}</label>
<input
id={id}
type="file"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={(e) => {
const uploadedFile = e.target.files?.[0];
if (!uploadedFile) {
setImg(null);
return;
}
new Compressor(uploadedFile, {
success(result) {
invariant(result instanceof Blob);
const file = new File([result], uploadedFile.name);
setImg(file);
},
error(err) {
logger.error(err.message);
},
});
new Compressor(uploadedFile, {
maxWidth: ART.THUMBNAIL_WIDTH,
success(result) {
invariant(result instanceof Blob);
const file = new File([result], uploadedFile.name);
setSmallImg(file);
},
error(err) {
logger.error(err.message);
},
});
}}
/>
{img && <img src={URL.createObjectURL(img)} alt="" className="mt-4" />}
</div>
);
}
function Description() {
const { t } = useTranslation(["art"]);
const data = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(data.art?.description ?? "");
const id = React.useId();
return (
<div>
<Label
htmlFor={id}
valueLimits={{ current: value.length, max: ART.DESCRIPTION_MAX_LENGTH }}
>
{t("art:forms.description.title")}
</Label>
<textarea
id={id}
name="description"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={ART.DESCRIPTION_MAX_LENGTH}
/>
</div>
);
}
// note: not handling edge case where a tag was added by another user while this
// user was adding a new art with the same tag -> will crash
function Tags() {
const { t } = useTranslation(["art", "common"]);
const data = useLoaderData<typeof loader>();
const [creationMode, setCreationMode] = React.useState(false);
const [tags, setTags] = React.useState<{ name?: string; id?: number }[]>(
data.art?.tags ?? [],
);
const [newTagValue, setNewTagValue] = React.useState("");
const handleAddNewTag = () => {
const normalizedNewTagValue = newTagValue
.trim()
// replace many whitespaces with one
.replace(/\s\s+/g, " ")
.toLowerCase();
if (
normalizedNewTagValue.length === 0 ||
normalizedNewTagValue.length > ART.TAG_MAX_LENGTH
) {
return;
}
const alreadyCreatedTag = data.tags.find(
(t) => t.name === normalizedNewTagValue,
);
if (alreadyCreatedTag) {
setTags((tags) => [...tags, alreadyCreatedTag]);
} else if (tags.every((tag) => tag.name !== normalizedNewTagValue)) {
setTags((tags) => [...tags, { name: normalizedNewTagValue }]);
}
setNewTagValue("");
setCreationMode(false);
};
return (
<div className="stack xs items-start">
<Label htmlFor="tags" className="mb-0">
{t("art:forms.tags.title")}
</Label>
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
{creationMode ? (
<div className="art__creation-mode-switcher-container">
<SendouButton
variant="minimal"
onPress={() => setCreationMode(false)}
>
{t("art:forms.tags.selectFromExisting")}
</SendouButton>
</div>
) : (
<div className="stack horizontal sm text-xs text-lighter art__creation-mode-switcher-container">
{t("art:forms.tags.cantFindExisting")}{" "}
<SendouButton variant="minimal" onPress={() => setCreationMode(true)}>
{t("art:forms.tags.addNew")}
</SendouButton>
</div>
)}
{tags.length >= ART.TAGS_MAX_LENGTH ? (
<div className="text-sm text-warning">
{t("art:forms.tags.maxReached")}
</div>
) : creationMode ? (
<div className="stack horizontal sm items-center">
<input
placeholder={t("art:forms.tags.addNew.placeholder")}
name="tag"
value={newTagValue}
onChange={(e) => setNewTagValue(e.target.value)}
onKeyDown={(event) => {
if (event.code === "Enter") {
handleAddNewTag();
}
}}
/>
<SendouButton
size="small"
variant="outlined"
onPress={handleAddNewTag}
>
{t("common:actions.add")}
</SendouButton>
</div>
) : (
<TagSelect
// empty combobox on select
key={tags.length}
tags={data.tags}
disabledKeys={tags.map((t) => t.id).filter((id) => id !== undefined)}
onSelectionChange={(tagName) =>
setTags([...tags, data.tags.find((t) => t.name === tagName)!])
}
/>
)}
<div className="text-sm stack sm flex-wrap horizontal">
{tags.map((t) => {
return (
<div key={t.name} className="stack horizontal">
{t.name}{" "}
<SendouButton
icon={<X />}
size="small"
variant="minimal-destructive"
className="art__delete-tag-button"
onPress={() => {
setTags(tags.filter((tag) => tag.name !== t.name));
}}
/>
</div>
);
})}
</div>
</div>
);
}
function LinkedUsers() {
const { t } = useTranslation(["art"]);
const data = useLoaderData<typeof loader>();
const [users, setUsers] = React.useState<
{ inputId: string; userId?: number }[]
>(
data.art?.linkedUsers && data.art.linkedUsers.length > 0
? data.art.linkedUsers.map((user) => ({
userId: user.id,
inputId: nanoid(),
}))
: [{ inputId: nanoid() }],
);
return (
<div>
<label htmlFor="user">{t("art:forms.linkedUsers.title")}</label>
<input
type="hidden"
name="linkedUsers"
value={JSON.stringify(
users.filter((u) => u.userId).map((u) => u.userId),
)}
/>
{users.map(({ inputId, userId }, i) => {
return (
<div key={inputId} className="stack horizontal sm mb-2 items-center">
<UserSearch
name="user"
onChange={(newUser) => {
const newUsers = structuredClone(users);
newUsers[i] = { ...newUsers[i], userId: newUser?.id };
setUsers(newUsers);
}}
initialUserId={userId}
/>
{users.length > 1 || users[0].userId ? (
<SendouButton
size="small"
variant="minimal-destructive"
onPress={() => {
if (users.length === 1) {
setUsers([{ inputId: nanoid() }]);
} else {
setUsers(users.filter((u) => u.inputId !== inputId));
}
}}
icon={<X />}
/>
) : null}
</div>
);
})}
<SendouButton
size="small"
onPress={() => setUsers([...users, { inputId: nanoid() }])}
isDisabled={users.length >= ART.LINKED_USERS_MAX_LENGTH}
className="my-3"
variant="outlined"
>
{t("art:forms.linkedUsers.anotherOne")}
</SendouButton>
<FormMessage type="info">{t("art:forms.linkedUsers.info")}</FormMessage>
</div>
);
}
function ShowcaseToggle() {
const { t } = useTranslation(["art"]);
const data = useLoaderData<typeof loader>();
const isCurrentlyShowcase = Boolean(data.art?.isShowcase);
const [checked, setChecked] = React.useState(isCurrentlyShowcase);
const id = React.useId();
return (
<div>
<label htmlFor={id}>{t("art:forms.showcase.title")}</label>
<SendouSwitch
isSelected={checked}
onChange={setChecked}
name="isShowcase"
id={id}
isDisabled={isCurrentlyShowcase}
/>
<FormMessage type="info">{t("art:forms.showcase.info")}</FormMessage>
</div>
);
}

View File

@@ -1,10 +1,10 @@
import { z } from "zod";
import { textFieldRequired } from "~/form/fields";
import { textField } from "~/form/fields";
import { _action, id, inviteCode } from "~/utils/zod";
import { ASSOCIATION } from "./associations-constants";
export const createNewAssociationSchema = z.object({
name: textFieldRequired({
name: textField({
label: "labels.name",
maxLength: 100,
}),

View File

@@ -12,6 +12,7 @@ export function BadgesSelector({
children,
maxCount,
showSelect = true,
disabled,
}: {
options: BadgeDisplayProps["badges"];
selectedBadges: number[];
@@ -20,6 +21,7 @@ export function BadgesSelector({
children?: React.ReactNode;
maxCount?: number;
showSelect?: boolean;
disabled?: boolean;
}) {
const { t } = useTranslation(["common"]);
@@ -35,7 +37,7 @@ export function BadgesSelector({
return aIdx - bIdx;
})}
onChange={onChange}
onChange={disabled ? undefined : onChange}
key={selectedBadges.join(",")}
>
{children}
@@ -56,7 +58,9 @@ export function BadgesSelector({
onChange={(e) =>
onChange([...selectedBadges, Number(e.target.value)])
}
disabled={Boolean(maxCount && selectedBadges.length >= maxCount)}
disabled={
disabled || Boolean(maxCount && selectedBadges.length >= maxCount)
}
data-testid="badges-selector"
>
<option>{t("common:badges.selector.select")}</option>

View File

@@ -1,4 +1,4 @@
import type { DamageType } from "./analyzer-types";
import type { DamageType, TenacityPlayerDeficit } from "./analyzer-types";
export const MAX_LDE_INTENSITY = 21;
@@ -109,3 +109,16 @@ export const MAX_AP = 57;
export const MAIN_SLOT_AP = 10;
export const SUB_SLOT_AP = 3;
/** How many active players the opponent's team has more than the user's team */
export const TENACITY_PLAYER_DEFICITS = [1, 2, 3] as const;
/** Special points Tenacity passively grants per second. Unaffected by Special Charge Up. */
export const TENACITY_SPECIAL_POINTS_PER_SECOND: Record<
TenacityPlayerDeficit,
number
> = {
1: 3.26,
2: 5.44,
3: 7.59,
};

View File

@@ -5,7 +5,10 @@ import type {
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import type { DAMAGE_TYPE } from "./analyzer-constants";
import type {
DAMAGE_TYPE,
TENACITY_PLAYER_DEFICITS,
} from "./analyzer-constants";
import type { SPECIAL_EFFECTS } from "./core/specialEffects";
import type { weaponParams } from "./data/weapon-params";
@@ -257,6 +260,8 @@ export interface FullInkTankOption {
export type DamageType = (typeof DAMAGE_TYPE)[number];
export type TenacityPlayerDeficit = (typeof TENACITY_PLAYER_DEFICITS)[number];
export interface Damage {
value: number;
type: DamageType;
@@ -284,6 +289,8 @@ export interface AnalyzedBuild {
specialPoint: Stat;
specialLost: Stat;
specialLostSplattedByRP: Stat;
/** Seconds it takes Tenacity to fill the special gauge, keyed by how many active players the user's team is down. Only set if the build has Tenacity. */
tenacitySecondsToSpecial?: Record<TenacityPlayerDeficit, number>;
mainWeaponWhiteInkSeconds?: number;
subWeaponWhiteInkSeconds: number;
subWeaponInkConsumptionPercentage: Stat;

View File

@@ -90,6 +90,58 @@ describe("Analyze build", () => {
).toBeGreaterThan(analyzedJr.stats.subWeaponInkConsumptionPercentage.value);
});
test("Tenacity special charge time is only calculated with Tenacity in the build", () => {
const analyzed = buildStats({
weaponSplId: 0,
hasTacticooler: false,
});
const analyzedWithTenacity = buildStats({
weaponSplId: 0,
mainOnlyAbilities: ["T"],
hasTacticooler: false,
});
expect(analyzed.stats.tenacitySecondsToSpecial).toBeUndefined();
expect(analyzedWithTenacity.stats.tenacitySecondsToSpecial).toBeDefined();
});
test("Tenacity special charge time is not affected by Special Charge Up", () => {
const analyzed = buildStats({
weaponSplId: 0,
mainOnlyAbilities: ["T"],
hasTacticooler: false,
});
const analyzedWithSCU = buildStats({
weaponSplId: 0,
abilityPoints: new Map([["SCU", 57]]),
mainOnlyAbilities: ["T"],
hasTacticooler: false,
});
expect(
analyzedWithSCU.stats.specialPoint.value,
"Special Charge Up should lower the points needed for special",
).toBeLessThan(analyzed.stats.specialPoint.value);
expect(analyzedWithSCU.stats.tenacitySecondsToSpecial).toEqual(
analyzed.stats.tenacitySecondsToSpecial,
);
});
test("Tenacity fills the special gauge faster the more players the team is down", () => {
const analyzed = buildStats({
weaponSplId: 0,
mainOnlyAbilities: ["T"],
hasTacticooler: false,
});
const secondsToSpecial = analyzed.stats.tenacitySecondsToSpecial!;
expect(secondsToSpecial[2]).toBeLessThan(secondsToSpecial[1]);
expect(secondsToSpecial[3]).toBeLessThan(secondsToSpecial[2]);
});
const subPowerApToQuickSuperJumpAp = new Map([
[0, 0],
[3, 4],

View File

@@ -28,6 +28,7 @@ import { assertUnreachable } from "~/utils/types";
import {
DAMAGE_TYPE,
RAINMAKER_SPEED_PENALTY_MODIFIER,
TENACITY_SPECIAL_POINTS_PER_SECOND,
} from "../analyzer-constants";
import type {
AbilityPoints,
@@ -38,6 +39,7 @@ import type {
SpecialWeaponParams,
StatFunctionInput,
SubWeaponParams,
TenacityPlayerDeficit,
} from "../analyzer-types";
import { INK_CONSUME_TYPES } from "../analyzer-types";
import type { abilityValues as abilityValuesJson } from "../data/ability-values";
@@ -109,6 +111,7 @@ export function buildStats({
specialPoint: specialPoint(input),
specialLost: specialLost(input),
specialLostSplattedByRP: specialLost(input, true),
tenacitySecondsToSpecial: tenacitySecondsToSpecial(input),
fullInkTankOptions: fullInkTankOptions(input),
damages: damages(input),
specialWeaponDamages: specialWeaponDamages(input),
@@ -210,6 +213,27 @@ function specialPoint({
};
}
function tenacitySecondsToSpecial({
mainWeaponParams,
mainOnlyAbilities,
}: StatFunctionInput): AnalyzedBuild["stats"]["tenacitySecondsToSpecial"] {
if (!mainOnlyAbilities.includes("T")) return;
// Special Charge Up does not affect the rate Tenacity fills the gauge at
// so the unmodified amount of points needed is used here
const secondsToSpecial = (playerDeficit: TenacityPlayerDeficit) =>
roundToNDecimalPlaces(
mainWeaponParams.SpecialPoint /
TENACITY_SPECIAL_POINTS_PER_SECOND[playerDeficit],
);
return {
1: secondsToSpecial(1),
2: secondsToSpecial(2),
3: secondsToSpecial(3),
};
}
const OWN_RESPAWN_PUNISHER_EXTRA_SPECIAL_LOST = 0.225;
const ENEMY_RESPAWN_PUNISHER_EXTRA_SPECIAL_LOST = 0.15;
function specialLost(

View File

@@ -22,6 +22,7 @@ import { Placeholder } from "~/components/Placeholder";
import { Table } from "~/components/Table";
import { WeaponSelect } from "~/components/WeaponSelect";
import { useUser } from "~/features/auth/core/user";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import { useHydrated } from "~/hooks/useHydrated";
import { abilitiesShort } from "~/modules/in-game-lists/abilities";
import type {
@@ -62,6 +63,7 @@ import {
damageTypeToWeaponType,
MAX_AP,
MAX_LDE_INTENSITY,
TENACITY_PLAYER_DEFICITS,
} from "../analyzer-constants";
import { useAnalyzeBuild } from "../analyzer-hooks";
import type {
@@ -154,6 +156,11 @@ function BuildAnalyzerPage() {
const objectShredderSelected = build[2][0] === "OS" || build2[2][0] === "OS";
const stealthJumpSelected = build[2][0] === "SJ" || build2[2][0] === "SJ";
// same for both builds as it only depends on the weapon
const tenacitySecondsToSpecial =
analyzed.stats.tenacitySecondsToSpecial ??
analyzed2.stats.tenacitySecondsToSpecial;
const context = {
isComparing: !buildIsEmpty(build) && !buildIsEmpty(build2),
mainWeaponId,
@@ -532,6 +539,27 @@ function BuildAnalyzerPage() {
title={t("analyzer:stat.specialLostSplattedByRP")}
suffix="%"
/>
{tenacitySecondsToSpecial
? TENACITY_PLAYER_DEFICITS.map((playerDeficit) => (
<StatCard
key={`tenacitySecondsToSpecial-${playerDeficit}`}
context={context}
stat={tenacitySecondsToSpecial[playerDeficit]}
title={t("analyzer:stat.tenacitySecondsToSpecial", {
count: playerDeficit,
})}
suffix={t("analyzer:suffix.seconds")}
staticValueAbility="T"
popoverInfo={t(
"analyzer:stat.tenacitySecondsToSpecial.explanation",
{
teamPlayerCount: FULL_GROUP_SIZE - playerDeficit,
opponentPlayerCount: FULL_GROUP_SIZE,
},
)}
/>
))
: null}
{analyzed.stats.specialDurationInSeconds && (
<StatCard
context={context}
@@ -1419,6 +1447,7 @@ function StatCard({
suffix,
popoverInfo,
testId,
staticValueAbility,
context: { mainWeaponId, abilityPoints, abilityPoints2, isComparing },
}: {
title: string;
@@ -1426,6 +1455,8 @@ function StatCard({
suffix?: string;
popoverInfo?: string;
testId?: string;
/** Ability the stat is tied to, shown for stats that have a static value */
staticValueAbility?: AbilityType;
context: {
mainWeaponId: MainWeaponId;
abilityPoints: AbilityPoints;
@@ -1549,7 +1580,11 @@ function StatCard({
</div>
{/* always render this so it reserves space */}
<div className={styles.statCardAbilityContainer}>
{!isStaticValue && (
{isStaticValue ? (
staticValueAbility ? (
<ModifiedByAbilities abilities={staticValueAbility} />
) : null
) : (
<>
<ModifiedByAbilities abilities={stat[0].modifiedBy} />
<StatChartPopover

View File

@@ -2,15 +2,15 @@ import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { parseFormData } from "~/form/parse.server";
import {
errorToastIfFalsy,
notFoundIfNullish,
parseParams,
safeParseRequestFormData,
} from "~/utils/remix.server";
import { calendarEventPage } from "~/utils/urls";
import { idObject } from "~/utils/zod";
import { reportWinnersActionSchema } from "../calendar-schemas";
import { reportWinnersFormSchema } from "../calendar-schemas";
import { canReportCalendarEventWinners } from "../calendar-utils";
export const action: ActionFunction = async (args) => {
@@ -19,15 +19,13 @@ export const action: ActionFunction = async (args) => {
params: args.params,
schema: idObject,
});
const parsedInput = await safeParseRequestFormData({
const result = await parseFormData({
request: args.request,
schema: reportWinnersActionSchema,
schema: reportWinnersFormSchema,
});
if (!parsedInput.success) {
return {
errors: parsedInput.errors,
};
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const event = notFoundIfNullish(await CalendarRepository.findById(params.id));
@@ -42,15 +40,8 @@ export const action: ActionFunction = async (args) => {
await CalendarRepository.upsertReportedScores({
eventId: params.id,
participantCount: parsedInput.data.participantCount,
results: parsedInput.data.team.map((t) => ({
teamName: t.teamName,
placement: t.placement,
players: t.players.map((p) => ({
userId: typeof p === "string" ? null : p.id,
name: typeof p === "string" ? p : null,
})),
})),
participantCount: result.data.participantCount,
results: result.data.teams,
});
throw redirect(calendarEventPage(params.id));

View File

@@ -1,21 +1,23 @@
import { type ActionFunctionArgs, redirect } from "react-router";
import { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import {
parseRequestPayload,
parseSafeSearchParams,
} from "~/utils/remix.server";
import { parseFormData } from "~/form/parse.server";
import { parseSafeSearchParams } from "~/utils/remix.server";
import { calendarPage } from "~/utils/urls";
import { dayMonthYear } from "~/utils/zod";
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: calendarFiltersSearchParamsSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
await UserRepository.updateOwnPreferences({
defaultCalendarFilters: data,
defaultCalendarFilters: result.data,
});
const parsedSearchParams = parseSafeSearchParams({

View File

@@ -94,6 +94,8 @@ export const EXCLUDED_TAGS: Array<CalendarEventTag> = ["CARDS", "SR"];
export const CALENDAR_EVENT_RESULT = {
MAX_PARTICIPANTS_COUNT: 1000,
MAX_TEAMS_COUNT: 100,
DEFAULT_PLAYERS_LENGTH: 4,
MAX_PLAYERS_LENGTH: 8,
MAX_TEAM_NAME_LENGTH: 100,
MAX_TEAM_PLACEMENT: 256,

View File

@@ -5,16 +5,17 @@ import {
badges,
checkboxGroup,
customField,
datetime,
datetimeOptional,
datetimeRequired,
hidden,
idConstantOptional,
image,
numberFieldOptional,
select,
selectDynamicOptional,
textAreaOptional,
textField,
textFieldOptional,
textFieldRequired,
toggle,
} from "~/form/fields";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
@@ -23,7 +24,7 @@ import { bracketProgressionSchema } from "./calendar-schemas";
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
/** Single date row of the {@link calendarNewBaseSchema} `date` array (calendar events). */
const calendarEventDateField = datetimeRequired({
const calendarEventDateField = datetime({
label: "labels.date",
min: calendarEventMinDate,
max: calendarEventMaxDate,
@@ -31,10 +32,10 @@ const calendarEventDateField = datetimeRequired({
export const calendarNewBaseSchema = z.object({
// discriminates between a calendar event and a tournament; seeded from the loader, no visible control
toToolsEnabled: customField({ initialValue: false }, z.boolean()),
toToolsEnabled: hidden(z.boolean(), false),
eventToEditId: idConstantOptional(),
tournamentToCopyId: idConstantOptional(),
name: textFieldRequired({
name: textField({
label: "labels.name",
minLength: CALENDAR_EVENT.NAME_MIN_LENGTH,
maxLength: CALENDAR_EVENT.NAME_MAX_LENGTH,

View File

@@ -9,8 +9,12 @@ import * as Progression from "~/features/tournament-bracket/core/Progression";
import {
array,
checkboxGroup,
customField,
fieldset,
numberField,
numberFieldOptional,
radioGroup,
textField,
textFieldOptional,
toggle,
userSearchOptional,
@@ -18,12 +22,10 @@ import {
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
import {
actualNumber,
gamesShortSchema,
id,
modeShortWithSpecial,
safeJSONParse,
toArray,
} from "~/utils/zod";
import { CALENDAR_EVENT, CALENDAR_EVENT_RESULT } from "./calendar-constants";
import * as CalendarEvent from "./core/CalendarEvent";
@@ -181,67 +183,116 @@ export const calendarFiltersSearchParamsObject = z.object({
.catch(CalendarEvent.defaultFilters()),
});
const playersSchema = z
.array(
z.union([
z.string().min(1).max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH),
z.object({ id }),
]),
)
.nonempty({ message: "forms.errors.emptyTeam" })
const reportedPlayerSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("USER"), id: id.nullable() }),
z.object({
type: z.literal("NAME"),
name: z
.string()
.max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH)
.nullable(),
}),
]);
export type ReportedPlayer = z.infer<typeof reportedPlayerSchema>;
export const EMPTY_REPORTED_PLAYER: ReportedPlayer = { type: "USER", id: null };
type StoredReportedPlayer = { userId: number | null; name: string | null };
const reportedPlayersSchema = z
.array(reportedPlayerSchema)
.max(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH)
.transform((players) =>
players.flatMap((player): Array<StoredReportedPlayer> => {
if (player.type === "USER") {
return player.id === null ? [] : [{ userId: player.id, name: null }];
}
return player.name ? [{ userId: null, name: player.name }] : [];
}),
)
.refine((players) => players.length > 0, {
message: "forms:errors.emptyTeam",
})
.refine(
(val) => {
const userIds = val.flatMap((user) =>
typeof user === "string" ? [] : user.id,
);
(players) => {
const userIds = players.flatMap((player) => player.userId ?? []);
return userIds.length === new Set(userIds).size;
},
{
message: "forms.errors.duplicatePlayer",
},
{ message: "forms:errors.duplicatePlayer" },
);
export const reportWinnersActionSchema = z.object({
participantCount: z.preprocess(
actualNumber,
z
.number()
.int()
.positive()
.max(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT),
),
team: z.preprocess(
toArray,
z
.array(
z.preprocess(
safeJSONParse,
z.object({
teamName: z
.string()
.min(1)
.max(CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH),
placement: z.preprocess(
actualNumber,
z
.number()
.int()
.positive()
.max(CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT),
),
players: playersSchema,
}),
),
)
.refine(
(val) => val.length === new Set(val.map((team) => team.teamName)).size,
{ message: "forms.errors.uniqueTeamName" },
),
),
const reportedTeamFieldset = fieldset({
fields: z.object({
teamName: textField({
label: "labels.teamName",
maxLength: CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH,
}),
placement: numberField({
label: "labels.placement",
maxLength: String(CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT).length,
}),
players: customField(
{
initialValue: new Array(
CALENDAR_EVENT_RESULT.DEFAULT_PLAYERS_LENGTH,
).fill(EMPTY_REPORTED_PLAYER),
},
reportedPlayersSchema,
),
}),
});
export const reportWinnersFormSchema = z
.object({
participantCount: numberField({
label: "labels.participantCount",
maxLength: String(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT).length,
}),
teams: array({
label: "labels.teams",
min: 1,
max: CALENDAR_EVENT_RESULT.MAX_TEAMS_COUNT,
field: reportedTeamFieldset,
}),
})
.superRefine((data, ctx) => {
if (
data.participantCount < 1 ||
data.participantCount > CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "forms:errors.numberOutOfRange",
path: ["participantCount"],
});
}
for (const [index, team] of data.teams.entries()) {
if (
team.placement < 1 ||
team.placement > CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "forms:errors.numberOutOfRange",
path: ["teams", index, "placement"],
});
}
}
const teamNames = data.teams.map((team) => team.teamName);
if (teamNames.length !== new Set(teamNames).size) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "forms:errors.uniqueTeamName",
path: ["teams"],
});
}
});
export const bracketProgressionSchema = z.preprocess(
safeJSONParse,
z

View File

@@ -1,18 +1,26 @@
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Form, useLoaderData } from "react-router";
import { useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { UserSearch } from "~/components/elements/UserSearch";
import { FormErrors } from "~/components/FormErrors";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import type { CustomFieldRenderProps } from "~/form/FormField";
import { useTranslatedTexts } from "~/form/fields/FormFieldWrapper";
import { SendouForm } from "~/form/SendouForm";
import type { ArrayItemRenderContext } from "~/form/types";
import { errorMessageId } from "~/form/utils";
import type { SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
import { action } from "../actions/calendar.$id.report-winners.server";
import { CALENDAR_EVENT_RESULT } from "../calendar-constants";
import {
EMPTY_REPORTED_PLAYER,
type ReportedPlayer,
reportWinnersFormSchema,
} from "../calendar-schemas";
import { loader } from "../loaders/calendar.$id.report-winners.server";
export { action, loader };
@@ -22,290 +30,108 @@ export const handle: SendouRouteHandle = {
};
export default function ReportWinnersPage() {
const { t } = useTranslation(["common", "calendar"]);
const { t } = useTranslation(["calendar"]);
const data = useLoaderData<typeof loader>();
return (
<Main halfWidth>
<Form method="post" className="stack md-plus items-start">
<h1 className="text-lg">
{t("calendar:forms.reportResultsHeader", { eventName: data.name })}
</h1>
<ParticipantsCountInput />
<FormMessage type="info">
{t("calendar:forms.reportResultsInfo")}
</FormMessage>
<TeamInputs />
<SendouButton type="submit" className="mt-4">
{t("common:actions.submit")}
</SendouButton>
<FormErrors namespace="calendar" />
</Form>
<SendouForm
schema={reportWinnersFormSchema}
title={t("calendar:forms.reportResultsHeader", {
eventName: data.name,
})}
defaultValues={{
participantCount: data.participantCount ?? undefined,
teams: data.winners.map((team) => ({
teamName: team.teamName,
placement: team.placement,
players: team.players.map(playerToFormValue),
})),
}}
>
{({ FormField }) => (
<>
<FormField name="participantCount" />
<FormMessage type="info">
{t("calendar:forms.reportResultsInfo")}
</FormMessage>
<FormField name="teams">
{({ itemName }: ArrayItemRenderContext) => (
<div className="stack md">
<FormField name={`${itemName}.teamName`} />
<FormField name={`${itemName}.placement`} />
<FormField name={`${itemName}.players`}>
{(props: CustomFieldRenderProps) => (
<PlayersFormField {...props} />
)}
</FormField>
</div>
)}
</FormField>
</>
)}
</SendouForm>
</Main>
);
}
function ParticipantsCountInput() {
const { t } = useTranslation("calendar");
const data = useLoaderData<typeof loader>();
type LoadedPlayer = Unpacked<
Unpacked<SerializeFrom<typeof loader>["winners"]>["players"]
>;
return (
<div>
<Label htmlFor="name" required>
{t("forms.participantCount")}
</Label>
<input
name="participantCount"
type="number"
required
min={1}
max={CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT}
defaultValue={data.participantCount ?? undefined}
className="w-24"
/>
</div>
);
function playerToFormValue(player: LoadedPlayer): ReportedPlayer {
return typeof player.id === "number"
? { type: "USER", id: player.id }
: { type: "NAME", name: player.name };
}
function TeamInputs() {
const { t } = useTranslation("calendar");
const data = useLoaderData<typeof loader>();
const [amountOfTeams, setAmountOfTeams] = React.useState(
Math.max(data.winners.length, 1),
);
function PlayersFormField({
name,
value,
onChange,
error,
}: CustomFieldRenderProps) {
const { t } = useTranslation(["calendar"]);
const { translatedError } = useTranslatedTexts({ error });
const players = value as Array<ReportedPlayer>;
const handleTeamDelete = () => {
setAmountOfTeams(amountOfTeams - 1);
const handlePlayerChange = (index: number, newPlayer: ReportedPlayer) => {
onChange(players.map((player, i) => (i === index ? newPlayer : player)));
};
return (
<>
<hr className="w-full" />
{new Array(amountOfTeams + 1).fill(null).map((_, i) => {
// last team is hidden so we can save its state even if user removes a filled team
const hidden = i === amountOfTeams;
return (
<React.Fragment key={i}>
<Team
onRemoveTeam={
i === amountOfTeams - 1 && amountOfTeams > 1
? handleTeamDelete
: undefined
}
hidden={hidden}
initialPlacement={String(i + 1)}
initialValues={data.winners[i]}
/>
{!hidden && <hr className="w-full" />}
</React.Fragment>
);
})}
<SendouButton
onPress={() => setAmountOfTeams((amountOfTeams) => amountOfTeams + 1)}
size="small"
>
{t("forms.team.add")}
</SendouButton>
</>
);
}
const NEW_PLAYER = { id: 0 } as const;
interface TeamResults {
teamName: string;
placement: string;
players: Array<
| {
id: number;
}
| string
>;
}
function Team({
onRemoveTeam,
hidden,
initialPlacement,
initialValues,
}: {
onRemoveTeam?: () => void;
hidden: boolean;
initialPlacement: string;
initialValues?: Unpacked<SerializeFrom<typeof loader>["winners"]>;
}) {
const { t } = useTranslation("calendar");
const teamNameId = React.useId();
const placementId = React.useId();
const [results, setResults] = React.useState<TeamResults>({
teamName: initialValues?.teamName ?? "",
placement: String(initialValues?.placement ?? initialPlacement),
players: initialValues?.players
? (initialValues.players.map((player) =>
player.name ? player.name : player,
) as TeamResults["players"])
: [NEW_PLAYER, NEW_PLAYER, NEW_PLAYER, NEW_PLAYER],
});
const handleTeamNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setResults({ ...results, teamName: e.target.value });
};
const handlePlacementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setResults({ ...results, placement: e.target.value });
};
const handleSetPlayers = React.useCallback(
(action: React.SetStateAction<TeamResults["players"]>) => {
setResults((prev) => ({
...prev,
players: typeof action === "function" ? action(prev.players) : action,
}));
},
[],
);
if (hidden) return null;
return (
<div className={clsx("stack md items-start")}>
<input
type="hidden"
name="team"
value={JSON.stringify({
...results,
players: results.players.filter(
(player) =>
(typeof player === "string" && player !== "") ||
(typeof player === "object" && player.id !== 0),
),
})}
/>
<div className="stack horizontal md flex-wrap">
<div>
<Label htmlFor={teamNameId}>{t("forms.team.name")}</Label>
<input
id={teamNameId}
value={results.teamName}
onChange={handleTeamNameChange}
required
maxLength={CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH}
/>
</div>
<div>
<Label htmlFor={placementId}>{t("forms.team.placing")}</Label>
<input
id={placementId}
value={results.placement}
type="number"
onChange={handlePlacementChange}
required
max={CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT}
className="w-24"
/>
</div>
</div>
<Players players={results.players} setPlayers={handleSetPlayers} />
{onRemoveTeam && (
<SendouButton
onPress={onRemoveTeam}
size="small"
variant="minimal-destructive"
className="mt-4"
>
{t("forms.team.remove")}
</SendouButton>
)}
</div>
);
}
function Players({
players,
setPlayers,
}: {
players: TeamResults["players"];
setPlayers: React.Dispatch<React.SetStateAction<TeamResults["players"]>>;
}) {
const { t } = useTranslation("calendar");
const handleAddPlayer = () => {
setPlayers([...players, NEW_PLAYER]);
};
const handleRemovePlayer = () => {
setPlayers(players.slice(0, -1));
};
const handlePlayerInputTypeChange = (index: number) => {
const newPlayers = [...players];
newPlayers[index] = typeof newPlayers[index] === "string" ? NEW_PLAYER : "";
setPlayers(newPlayers);
};
const handleInputChange = React.useCallback(
(index: number, newValue: string | number) => {
setPlayers((prev) => {
const newPlayers = [...prev];
newPlayers[index] =
typeof newValue === "string" ? newValue : { id: newValue };
return newPlayers;
});
},
[setPlayers],
);
return (
<div className="stack md">
{players.map((player, i) => {
const formId = `player-${i + 1}`;
const asPlainInput = typeof player === "string";
return (
<div key={i}>
<div className="stack horizontal md items-center mb-1">
<label htmlFor={formId} className="mb-0">
{t("forms.team.player.header", { number: i + 1 })}
</label>
<SendouButton
size="small"
variant="minimal"
onPress={() => handlePlayerInputTypeChange(i)}
>
{asPlainInput
? t("forms.team.player.addAsUser")
: t("forms.team.player.addAsText")}
</SendouButton>
</div>
<PlayerInput
formId={formId}
player={player}
index={i}
asPlainInput={asPlainInput}
onInputChange={handleInputChange}
/>
</div>
);
})}
{players.map((player, i) => (
<PlayerInput
key={i}
index={i}
player={player}
onPlayerChange={handlePlayerChange}
/>
))}
{translatedError ? (
<FormMessage type="error" id={errorMessageId(name)}>
{translatedError}
</FormMessage>
) : null}
<div className="stack horizontal sm mt-2">
<SendouButton
size="small"
onPress={handleAddPlayer}
variant="outlined"
onPress={() => onChange([...players, EMPTY_REPORTED_PLAYER])}
isDisabled={
players.length === CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH
}
variant="outlined"
>
{t("forms.team.player.add")}
</SendouButton>{" "}
{t("calendar:forms.team.player.add")}
</SendouButton>
<SendouButton
size="small"
variant="destructive"
onPress={handleRemovePlayer}
onPress={() => onChange(players.slice(0, -1))}
isDisabled={players.length === 1}
>
{t("forms.team.player.remove")}
{t("calendar:forms.team.player.remove")}
</SendouButton>
</div>
</div>
@@ -313,50 +139,59 @@ function Players({
}
function PlayerInput({
formId,
player,
index,
asPlainInput,
onInputChange,
player,
onPlayerChange,
}: {
formId: string;
player: TeamResults["players"][number];
index: number;
asPlainInput: boolean;
onInputChange: (index: number, newValue: string | number) => void;
player: ReportedPlayer;
onPlayerChange: (index: number, newPlayer: ReportedPlayer) => void;
}) {
const handlePlainChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onInputChange(index, e.target.value);
},
[index, onInputChange],
);
const { t } = useTranslation(["calendar"]);
const id = React.useId();
const handleUserChange = React.useCallback(
(newUser: { id: number } | null) => {
if (!newUser) return;
onInputChange(index, newUser.id);
},
[index, onInputChange],
);
if (asPlainInput) {
return (
<input
id={formId}
value={player as string}
onChange={handlePlainChange}
max={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
/>
);
}
const asPlainInput = player.type === "NAME";
const label = t("calendar:forms.team.player.header", { number: index + 1 });
return (
<UserSearch
id={formId}
name="team-player"
initialUserId={(player as { id: number }).id}
onChange={handleUserChange}
/>
<div className="stack horizontal sm items-end">
<div className="w-full">
{player.type === "NAME" ? (
<>
<Label htmlFor={id}>{label}</Label>
<input
id={id}
value={player.name ?? ""}
onChange={(e) =>
onPlayerChange(index, { type: "NAME", name: e.target.value })
}
maxLength={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
/>
</>
) : (
<UserSearch
label={label}
initialUserId={player.id ?? undefined}
onChange={(user) =>
onPlayerChange(index, { type: "USER", id: user?.id ?? null })
}
/>
)}
</div>
<SendouButton
size="small"
variant="minimal"
onPress={() =>
onPlayerChange(
index,
asPlainInput ? EMPTY_REPORTED_PLAYER : { type: "NAME", name: "" },
)
}
>
{asPlainInput
? t("calendar:forms.team.player.addAsUser")
: t("calendar:forms.team.player.addAsText")}
</SendouButton>
</div>
);
}

View File

@@ -2,9 +2,9 @@ import { z } from "zod";
import {
checkboxGroup,
customField,
datetime,
datetimeOptional,
datetimeRequired,
dayMonthYearRequired,
dayMonthYear,
dualSelectOptional,
image,
numberFieldOptional,
@@ -13,10 +13,10 @@ import {
selectDynamicOptional,
selectOptional,
stageSelect,
textArea,
textAreaOptional,
textAreaRequired,
textField,
textFieldOptional,
textFieldRequired,
timeRangeOptional,
toggle,
userSearchOptional,
@@ -26,7 +26,7 @@ import {
export const formFieldsShowcaseSchema = z.object({
// Text fields
requiredText: textFieldRequired({
requiredText: textField({
label: "labels.name",
maxLength: 100,
}),
@@ -39,7 +39,7 @@ export const formFieldsShowcaseSchema = z.object({
}),
// Text areas
requiredTextArea: textAreaRequired({
requiredTextArea: textArea({
label: "labels.description",
maxLength: 500,
}),
@@ -119,13 +119,13 @@ export const formFieldsShowcaseSchema = z.object({
}),
// Date & Time
requiredDatetime: datetimeRequired({
requiredDatetime: datetime({
label: "labels.startTime",
}),
optionalDatetime: datetimeOptional({
label: "labels.vodDate",
}),
birthDate: dayMonthYearRequired({
birthDate: dayMonthYear({
label: "labels.banUserExpiresAt",
}),
availableTime: timeRangeOptional({

View File

@@ -2102,14 +2102,14 @@ function FormFieldsSection({ id }: { id: string }) {
<SendouForm
schema={formFieldsShowcaseSchema}
autoSubmit
mode="autoSubmit"
className="w-full"
>
{({ FormField }) => (
<div className="stack lg">
<Divider smallText>Text Fields</Divider>
<ComponentRow label="textFieldRequired">
<ComponentRow label="textField">
<FormField name="requiredText" />
</ComponentRow>
@@ -2123,7 +2123,7 @@ function FormFieldsSection({ id }: { id: string }) {
<Divider smallText>Text Areas</Divider>
<ComponentRow label="textAreaRequired">
<ComponentRow label="textArea">
<FormField name="requiredTextArea" />
</ComponentRow>
@@ -2173,7 +2173,7 @@ function FormFieldsSection({ id }: { id: string }) {
<Divider smallText>Date & Time</Divider>
<ComponentRow label="datetimeRequired">
<ComponentRow label="datetime">
<FormField name="requiredDatetime" />
</ComponentRow>
@@ -2181,7 +2181,7 @@ function FormFieldsSection({ id }: { id: string }) {
<FormField name="optionalDatetime" />
</ComponentRow>
<ComponentRow label="dayMonthYearRequired">
<ComponentRow label="dayMonthYear">
<FormField name="birthDate" />
</ComponentRow>

View File

@@ -9,10 +9,12 @@ import {
SendouMenuItem,
SendouMenuSection,
} from "~/components/elements/Menu";
import { TwitchIcon } from "~/components/icons/Twitch";
import { ListButton } from "~/components/SideNav";
import {
type FriendActivityBadge,
type FriendActivityType,
isLiveFriendActivity,
friendActivityBadge,
} from "~/features/friends/friends-constants";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import {
@@ -23,6 +25,11 @@ import {
tournamentSubsPage,
} from "~/utils/urls";
const ACTIVITY_BADGE_TRANSLATION_KEY = {
MATCH: "friends:friendsList.inMatch",
NEXT: "friends:friendsList.nextMatch",
} as const satisfies Record<FriendActivityBadge, string>;
export function FriendMenu({
discordId,
discordAvatar,
@@ -34,6 +41,7 @@ export function FriendMenu({
activityType,
matchId,
tournamentId,
streamUrl,
friendshipId,
friendshipCreatedAt,
onNavigate,
@@ -48,6 +56,7 @@ export function FriendMenu({
activityType: FriendActivityType | null;
matchId: number | null;
tournamentId: number | null;
streamUrl: string | null;
friendshipId?: number;
friendshipCreatedAt?: number | null;
onNavigate?: () => void;
@@ -67,7 +76,7 @@ export function FriendMenu({
})
: null;
const isLive = isLiveFriendActivity(activityType);
const activityBadge = friendActivityBadge(activityType);
const activity = resolveActivity({ activityType, matchId, tournamentId });
return (
@@ -77,8 +86,14 @@ export function FriendMenu({
<ListButton
user={{ discordId, discordAvatar, customAvatarUrl }}
subtitle={subtitle}
badge={isLive ? t("friends:friendsList.live") : badge}
badgeVariant={isLive ? "warning" : "default"}
badge={
streamUrl
? t("friends:friendsList.live")
: activityBadge
? t(ACTIVITY_BADGE_TRANSLATION_KEY[activityBadge])
: badge
}
badgeVariant={streamUrl ? "warning" : "default"}
>
{name}
</ListButton>
@@ -88,6 +103,17 @@ export function FriendMenu({
<SendouMenuItem href={url} icon={<User />} onAction={onNavigate}>
{t("friends:friendsList.viewUserPage")}
</SendouMenuItem>
{streamUrl ? (
<SendouMenuItem
href={streamUrl}
target="_blank"
rel="noreferrer"
icon={<TwitchIcon />}
onAction={onNavigate}
>
{t("friends:friendsList.watchStream")}
</SendouMenuItem>
) : null}
{activity?.type === "join-sendouq" ? (
<SendouMenuItem
icon={<Swords />}
@@ -189,7 +215,7 @@ function resolveActivity(friend: {
}),
} as const)
: null;
case "TOURNAMENT_PLAYING":
case "TOURNAMENT_WAITING":
return friend.tournamentId
? ({
type: "view-tournament",

View File

@@ -8,30 +8,33 @@ export const SENDOUQ_ACTIVITY_LABEL = "SendouQ";
export type FriendActivityType =
| "SENDOUQ_MATCH"
| "TOURNAMENT_MATCH"
| "TOURNAMENT_PLAYING"
| "TOURNAMENT_WAITING"
| "SENDOUQ"
| "TOURNAMENT_SUB";
/**
* Whether the activity represents a friend currently playing (in a live match
* or otherwise busy in a running tournament) as opposed to looking for members.
*/
export function isLiveFriendActivity(type: FriendActivityType | null) {
return (
type === "SENDOUQ_MATCH" ||
type === "TOURNAMENT_MATCH" ||
type === "TOURNAMENT_PLAYING"
);
export type FriendActivityBadge = "MATCH" | "NEXT";
const ACTIVITY_BADGE: Record<FriendActivityType, FriendActivityBadge | null> = {
SENDOUQ_MATCH: "MATCH",
TOURNAMENT_MATCH: "MATCH",
TOURNAMENT_WAITING: "NEXT",
SENDOUQ: null,
TOURNAMENT_SUB: null,
};
export function friendActivityBadge(type: FriendActivityType | null) {
if (!type) return null;
return ACTIVITY_BADGE[type];
}
export function isInProgressFriendActivity(type: FriendActivityType | null) {
return friendActivityBadge(type) !== null;
}
/**
* Sort value used to order friends by how interesting their activity is.
* Looking for members ranks highest (others can act on it), then live activity,
* then no activity.
*/
export function friendActivitySortValue(type: FriendActivityType | null) {
if (type === "SENDOUQ") return 4;
if (type === "TOURNAMENT_SUB") return 3;
if (isLiveFriendActivity(type)) return 2;
if (isInProgressFriendActivity(type)) return 2;
return 0;
}

View File

@@ -1,8 +1,13 @@
import { groupExpiryStatus } from "~/features/sendouq/core/groups";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import type { TournamentTeamMemberProgressStatus } from "~/features/tournament-bracket/core/Tournament";
import type {
Tournament,
TournamentTeamMemberProgressStatus,
} from "~/features/tournament-bracket/core/Tournament";
import { twitchUrl } from "~/utils/urls";
import {
type FriendActivityType,
SENDOUQ_ACTIVITY_LABEL,
@@ -14,6 +19,8 @@ export interface FriendActivity {
badge: string | null;
matchId: number | null;
tournamentId: number | null;
/** Set when the friend's current match can be watched, making the activity show up as "LIVE". */
streamUrl: string | null;
}
const TOURNAMENT_STATUS_IS_IN_PROGRESS: Record<
@@ -25,15 +32,32 @@ const TOURNAMENT_STATUS_IS_IN_PROGRESS: Record<
WAITING_FOR_CAST: true,
WAITING_FOR_ROUND: true,
WAITING_FOR_GROUPS: true,
// to counter 2 day tournaments showing LIVE in between
// to counter 2 day tournaments showing as in progress in between
WAITING_FOR_BRACKET: false,
CHECKIN: false,
THANKS_FOR_PLAYING: false,
};
/**
* Twitch account streaming each ongoing SendouQ match, keyed by match id. Resolved
* once per request as activity is resolved separately for every friend.
*/
export async function resolveSendouQMatchStreams() {
const streams = await cachedStreams();
const result = new Map<number, string>();
for (const { match, stream } of streams) {
if (!stream.twitchUserName || result.has(match.id)) continue;
result.set(match.id, stream.twitchUserName);
}
return result;
}
/**
* Resolves what a friend is currently doing for display in the friends list,
* prioritizing in-progress activity (a live SendouQ or tournament match) over
* prioritizing in-progress activity (an ongoing SendouQ or tournament match) over
* looking-for-members activity.
*/
export function resolveFriendActivity({
@@ -42,22 +66,27 @@ export function resolveFriendActivity({
tournamentName,
teamMemberCount,
tournamentMinTeamSize,
sendouQMatchStreams,
}: {
friendId: number;
tournamentId: number | null;
tournamentName: string | null;
teamMemberCount: number | null;
tournamentMinTeamSize: number | null;
sendouQMatchStreams: ReadonlyMap<number, string>;
}): FriendActivity {
const ownGroup = SendouQ.findOwnGroup(friendId);
if (ownGroup?.matchId) {
const twitchAccount = sendouQMatchStreams.get(ownGroup.matchId);
return {
type: "SENDOUQ_MATCH",
subtitle: SENDOUQ_ACTIVITY_LABEL,
badge: null,
matchId: ownGroup.matchId,
tournamentId: null,
streamUrl: twitchAccount ? twitchUrl(twitchAccount) : null,
};
}
@@ -75,6 +104,7 @@ export function resolveFriendActivity({
badge: `${ownGroup.members.length}/${FULL_GROUP_SIZE}`,
matchId: null,
tournamentId: null,
streamUrl: null,
};
}
@@ -85,6 +115,7 @@ export function resolveFriendActivity({
badge: `${teamMemberCount ?? 1}/${tournamentMinTeamSize ?? FULL_GROUP_SIZE}`,
matchId: null,
tournamentId,
streamUrl: null,
};
}
@@ -94,6 +125,7 @@ export function resolveFriendActivity({
badge: null,
matchId: null,
tournamentId: null,
streamUrl: null,
};
}
@@ -102,14 +134,79 @@ function resolveTournamentActivity(friendId: number): FriendActivity | null {
const status = tournament.teamMemberOfProgressStatus({ id: friendId });
if (!status || !TOURNAMENT_STATUS_IS_IN_PROGRESS[status.type]) continue;
const isInMatch = status.type === "MATCH";
return {
type: status.type === "MATCH" ? "TOURNAMENT_MATCH" : "TOURNAMENT_PLAYING",
type: isInMatch ? "TOURNAMENT_MATCH" : "TOURNAMENT_WAITING",
subtitle: tournament.ctx.name,
badge: null,
matchId: status.type === "MATCH" ? status.matchId : null,
matchId: isInMatch ? status.matchId : null,
tournamentId: tournament.ctx.id,
streamUrl: isInMatch
? tournamentStreamUrl({
tournament,
friendId,
matchId: status.matchId,
opponentId: status.opponentId,
})
: null,
};
}
return null;
}
/**
* Where the friend's ongoing tournament match can be watched, preferring the view
* that shows the friend best: their own stream, then a teammate's stream, then the
* official cast of the match and finally an opponent's.
*/
function tournamentStreamUrl({
tournament,
friendId,
matchId,
opponentId,
}: {
tournament: Tournament;
friendId: number;
matchId: number;
opponentId: number;
}) {
const streamingParticipantIds = new Set(tournament.streamingParticipantIds);
const ownTeamMembers =
tournament.teamMemberOfByUser({ id: friendId })?.members ?? [];
const friendAccount = streamingTwitchAccount(
ownTeamMembers.filter((member) => member.userId === friendId),
streamingParticipantIds,
);
if (friendAccount) return twitchUrl(friendAccount);
const teammateAccount = streamingTwitchAccount(
ownTeamMembers.filter((member) => member.userId !== friendId),
streamingParticipantIds,
);
if (teammateAccount) return twitchUrl(teammateAccount);
const castAccount = tournament.ctx.castedMatchesInfo?.castedMatches.find(
(castedMatch) => castedMatch.matchId === matchId,
)?.twitchAccount;
if (castAccount) return twitchUrl(castAccount);
const opponentAccount = streamingTwitchAccount(
tournament.teamById(opponentId)?.members ?? [],
streamingParticipantIds,
);
return opponentAccount ? twitchUrl(opponentAccount) : null;
}
function streamingTwitchAccount(
players: Array<{ userId: number; streamTwitch: string | null }>,
streamingParticipantIds: ReadonlySet<number>,
) {
return players.find(
(player) =>
streamingParticipantIds.has(player.userId) && player.streamTwitch,
)?.streamTwitch;
}

View File

@@ -3,19 +3,27 @@ import { requireUser } from "~/features/auth/core/user.server";
import { userPage } from "~/utils/urls";
import * as FriendRepository from "../FriendRepository.server";
import { friendActivitySortValue } from "../friends-constants";
import { resolveFriendActivity } from "../friends-utils.server";
import {
resolveFriendActivity,
resolveSendouQMatchStreams,
} from "../friends-utils.server";
export type FriendsLoaderData = typeof loader;
export const loader = async () => {
const user = requireUser();
const [friendsWithActivity, pendingRequests, incomingRequests] =
await Promise.all([
FriendRepository.findByUserIdWithActivity(user.id),
FriendRepository.findPendingSentRequests(user.id),
FriendRepository.findPendingReceivedRequests(user.id),
]);
const [
friendsWithActivity,
pendingRequests,
incomingRequests,
streamedSendouQMatches,
] = await Promise.all([
FriendRepository.findByUserIdWithActivity(user.id),
FriendRepository.findPendingSentRequests(user.id),
FriendRepository.findPendingReceivedRequests(user.id),
resolveSendouQMatchStreams(),
]);
const unique = R.uniqueBy(friendsWithActivity, (f) => f.id);
@@ -29,6 +37,7 @@ export const loader = async () => {
tournamentName: friend.tournamentName,
teamMemberCount: friend.teamMemberCount,
tournamentMinTeamSize: friend.tournamentMinTeamSize,
sendouQMatchStreams: streamedSendouQMatches,
});
return {
@@ -47,6 +56,7 @@ export const loader = async () => {
activityType: activity.type,
matchId: activity.matchId,
tournamentId: activity.tournamentId ?? friend.tournamentId,
streamUrl: activity.streamUrl,
friendshipCreatedAt: friend.friendshipCreatedAt,
};
}),
@@ -64,6 +74,7 @@ export const loader = async () => {
tournamentName: tm.tournamentName,
teamMemberCount: tm.teamMemberCount,
tournamentMinTeamSize: tm.tournamentMinTeamSize,
sendouQMatchStreams: streamedSendouQMatches,
});
return {
@@ -81,6 +92,7 @@ export const loader = async () => {
activityType: activity.type,
matchId: activity.matchId,
tournamentId: activity.tournamentId ?? tm.tournamentId,
streamUrl: activity.streamUrl,
};
}),
[(tm) => friendActivitySortValue(tm.activityType), "desc"],

View File

@@ -0,0 +1,53 @@
import invariant from "~/utils/invariant";
export type ImageExtension = "webp" | "png" | "jpeg";
/**
* Decodes a base64 image data URL to a buffer, resolving the format from the buffer's own magic
* bytes rather than trusting the client-declared mime type, and asserting it is one of
* `allowedExtensions`.
*/
export function dataUrlToImageBuffer(
dataUrl: string,
allowedExtensions: ReadonlyArray<ImageExtension>,
) {
const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
const buffer = Buffer.from(base64, "base64");
const extension = imageExtensionFromMagicBytes(buffer);
invariant(
extension && allowedExtensions.includes(extension),
`Submitted image is not a valid ${allowedExtensions.join(" or ")}`,
);
return { buffer, extension };
}
function imageExtensionFromMagicBytes(buffer: Buffer): ImageExtension | null {
if (
buffer.length > 12 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return "webp";
}
if (
buffer.length > 8 &&
buffer[0] === 0x89 &&
buffer.toString("ascii", 1, 4) === "PNG"
) {
return "png";
}
if (
buffer.length > 3 &&
buffer[0] === 0xff &&
buffer[1] === 0xd8 &&
buffer[2] === 0xff
) {
return "jpeg";
}
return null;
}

View File

@@ -7,6 +7,7 @@ import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import { errorToastIfFalsy } from "~/utils/remix.server";
import * as ImageRepository from "./ImageRepository.server";
import { dataUrlToImageBuffer } from "./image-bytes.server";
import { uploadStreamToS3 } from "./s3.server";
import { MAX_UNVALIDATED_IMG_COUNT } from "./upload-constants";
@@ -44,7 +45,11 @@ export async function imageFieldValueToImgId({
);
}
const { buffer, extension } = dataUrlToImageBuffer(value.dataUrl);
// the client compresses to webp, but browsers without canvas webp encoding fall back to png
const { buffer, extension } = dataUrlToImageBuffer(value.dataUrl, [
"webp",
"png",
]);
const uploadedFileLocation = await uploadStreamToS3(
Readable.from(buffer),
@@ -63,37 +68,3 @@ export async function imageFieldValueToImgId({
return img.id;
}
function dataUrlToImageBuffer(dataUrl: string) {
const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
const buffer = Buffer.from(base64, "base64");
const extension = imageExtensionFromMagicBytes(buffer);
invariant(extension, "Submitted image is not a valid webp or png");
return { buffer, extension };
}
/**
* Resolves the image format from the buffer's magic bytes. The client compresses to webp,
* but browsers without canvas webp encoding silently fall back to png.
*/
function imageExtensionFromMagicBytes(buffer: Buffer): "webp" | "png" | null {
if (
buffer.length > 12 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return "webp";
}
if (
buffer.length > 8 &&
buffer[0] === 0x89 &&
buffer.toString("ascii", 1, 4) === "PNG"
) {
return "png";
}
return null;
}

View File

@@ -1,5 +1,3 @@
export const ALLOWED_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
export const MAX_UNVALIDATED_IMG_COUNT = 5;
export const IMAGES_TO_VALIDATE_AT_ONCE = 5;

View File

@@ -5,7 +5,7 @@ import {
idConstantOptional,
selectDynamic,
selectDynamicOptional,
textAreaRequired,
textArea,
} from "~/form/fields";
import { LFG, TIMEZONES } from "./lfg-constants";
@@ -14,7 +14,7 @@ export const lfgNewSchema = z
postId: idConstantOptional(),
type: selectDynamic({ label: "labels.type" }),
timezone: selectDynamic({ label: "labels.timezone" }),
postText: textAreaRequired({
postText: textArea({
label: "labels.text",
maxLength: LFG.MAX_TEXT_LENGTH,
}),

View File

@@ -3,7 +3,7 @@ import {
idConstant,
selectDynamic,
stringConstant,
textAreaRequired,
textArea,
userSearch,
} from "~/form/fields";
import { _action, actualNumber } from "~/utils/zod";
@@ -12,13 +12,13 @@ import { PLUS_TIERS } from "./plus-suggestions-constants";
export const followUpCommentFormSchema = z.object({
tier: idConstant(),
suggestedId: idConstant(),
comment: textAreaRequired({
comment: textArea({
label: "labels.comment",
maxLength: 280,
}),
});
const suggestionTextFormFieldSchema = textAreaRequired({
const suggestionTextFormFieldSchema = textArea({
label: "labels.comment",
maxLength: 500,
});

View File

@@ -1,13 +1,13 @@
import type { ActionFunctionArgs } from "react-router";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
import { parseFormData } from "~/form/parse.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import {
errorToast,
errorToastIfFalsy,
notFoundIfNullish,
parseParams,
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { idObject } from "~/utils/zod";
@@ -26,11 +26,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const post = notFoundIfNullish(await ScrimPostRepository.findById(id));
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: scrimIdActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
requirePermission(post, "MANAGE_TRACKING");
switch (data._action) {

View File

@@ -8,6 +8,7 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { datePlaceholder } from "~/features/chat/chat-utils";
import { notify } from "~/features/notifications/core/notify.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import {
databaseTimestampToDate,
@@ -18,29 +19,31 @@ import {
DuplicateEntryError,
} from "~/utils/errors";
import { logger } from "~/utils/logger";
import {
actionError,
errorToast,
errorToastIfFalsy,
parseRequestPayload,
} from "~/utils/remix.server";
import { errorToast, errorToastIfFalsy } from "~/utils/remix.server";
import { toDBBoolean } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { navIconUrl, scrimPage, scrimsPage } from "~/utils/urls";
import * as Scrim from "../core/Scrim";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { SCRIM } from "../scrims-constants";
import { type newRequestSchema, scrimsActionSchema } from "../scrims-schemas";
import { scrimsActionSchema } from "../scrims-schemas";
import { generateTimeOptions } from "../scrims-utils";
import { usersListForPost } from "./scrims.new.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: scrimsActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
switch (data._action) {
case "DELETE_POST": {
const post = await findPost({
@@ -75,10 +78,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
if (post.rangeEndsAt && !data.at) {
return actionError<typeof newRequestSchema>({
msg: "Please select a time for the scrim",
field: "at",
});
return {
fieldErrors: { at: "Please select a time for the scrim" },
};
}
if (post.rangeEndsAt && data.at) {
@@ -89,10 +91,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const requestTime = data.at.getTime();
if (!validTimeOptions.includes(requestTime)) {
return actionError<typeof newRequestSchema>({
msg: "Selected time must be one of the available options",
field: "at",
});
return {
fieldErrors: {
at: "Selected time must be one of the available options",
},
};
}
}

View File

@@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import type { z } from "zod";
import { SendouDatePicker } from "~/components/elements/DatePicker";
import { TournamentSearch } from "~/components/elements/TournamentSearch";
import { Label } from "~/components/Label";
import type { CustomFieldRenderProps } from "~/form";
import { FormField } from "~/form/FormField";
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { errorMessageId } from "~/form/utils";
@@ -90,7 +90,7 @@ export default function NewScrimPage() {
<FormField name="maps" />
<TournamentSearchFormField />
<MapsTournamentFormField />
<FormField name="postText" />
@@ -272,14 +272,9 @@ function AssociationSelect({
);
}
function TournamentSearchFormField() {
const { t } = useTranslation(["scrims"]);
const { values, setValue, clientErrors, serverErrors } =
useFormFieldContext();
function MapsTournamentFormField() {
const { values, setValue } = useFormFieldContext();
const maps = values.maps as string;
const mapsTournamentId = values.mapsTournamentId as number | null;
const error = serverErrors.mapsTournamentId ?? clientErrors.mapsTournamentId;
const prevMaps = React.useRef(maps);
React.useEffect(() => {
@@ -293,19 +288,5 @@ function TournamentSearchFormField() {
if (maps !== "TOURNAMENT") return null;
return (
<FormFieldWrapper
id="mapsTournamentId"
name="mapsTournamentId"
error={error}
>
<TournamentSearch
label={t("scrims:forms.mapsTournament.title")}
initialTournamentId={mapsTournamentId ?? undefined}
onChange={(tournament) =>
setValue("mapsTournamentId", tournament?.id ?? null)
}
/>
</FormFieldWrapper>
);
return <FormField name="mapsTournamentId" />;
}

View File

@@ -2,7 +2,7 @@ import { add, sub } from "date-fns";
import { z } from "zod";
import {
customField,
datetimeRequired,
datetime,
dualSelectOptional,
idConstant,
radioGroupDynamic,
@@ -11,8 +11,8 @@ import {
selectOptional,
stageSelect,
stringConstant,
textArea,
textAreaOptional,
textAreaRequired,
textFieldOptional,
timeRangeOptional,
toggle,
@@ -79,7 +79,7 @@ const cancelRequestSchema = z.object({
export const cancelScrimFormSchema = z.object({
_action: stringConstant("CANCEL_SCRIM"),
reason: textAreaRequired({
reason: textArea({
label: "labels.scrimCancelReason",
bottomText: "bottomTexts.scrimCancelReasonHelp",
maxLength: SCRIM.CANCEL_REASON_MAX_LENGTH,
@@ -309,7 +309,7 @@ const mapsItems = [
export const scrimsNewFormSchema = z
.object({
at: datetimeRequired({
at: datetime({
label: "labels.start",
bottomText: "bottomTexts.scrimStart",
min: () => sub(new Date(), { days: 1 }),
@@ -357,10 +357,9 @@ export const scrimsNewFormSchema = z
label: "labels.scrimMaps",
items: [...mapsItems],
}),
mapsTournamentId: customField(
{ initialValue: null },
z.preprocess(falsyToNull, id.nullable()),
),
mapsTournamentId: tournamentSearchOptional({
label: "labels.scrimMapsTournament",
}),
})
.superRefine((post, ctx) => {
if (post.maps === "TOURNAMENT" && !post.mapsTournamentId) {

View File

@@ -42,7 +42,10 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
: groupNames.bravo
: undefined;
const bottomRow = (
const awaitingConfirmation =
!data.match.isLocked && SendouQMatch.score(data.match).isDecisive;
const bottomRow = data.match.isLocked ? null : (
<MatchBannerBottomRow
games={data.match.mapList.map((map) => ({
mode: map.mode,
@@ -54,9 +57,6 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
/>
);
const awaitingConfirmation =
!data.match.isLocked && SendouQMatch.score(data.match).isDecisive;
if (data.match.isLocked || awaitingConfirmation) {
const playedStageIds = data.match.mapList
.filter((m) => m.winnerGroupId !== null)
@@ -154,7 +154,14 @@ function SendouQMatchBannerTopRow({
}}
>
{data.match.isLocked || awaitingConfirmation ? (
<MatchBannerStartedAt time={startedAt} />
<MatchBannerStartedAt
time={startedAt}
endTime={
lastMapReportedAt
? databaseTimestampToDate(lastMapReportedAt)
: null
}
/>
) : (
<MatchBannerTimer
time={{

View File

@@ -10,7 +10,8 @@ import {
} from "~/features/sendouq-match/core/match.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { parseFormData } from "~/form/parse.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { navIconUrl, SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
import { groupAfterMorph } from "../core/groups";
@@ -25,10 +26,17 @@ import { SendouQError, setGroupChatMetadata } from "../q-utils.server";
// and when we return null we just force a refresh
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: lookingSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const currentGroup = SendouQ.findOwnGroup(user.id);
if (!currentGroup) return null;

View File

@@ -7,13 +7,15 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { parseFormData } from "~/form/parse.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import {
SENDOUQ_LOOKING_PAGE,
SENDOUQ_PREPARING_PAGE,
SUSPENDED_PAGE,
} from "~/utils/urls";
import { normalizeFriendCode } from "~/utils/zod";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
import {
JOIN_CODE_SEARCH_PARAM_KEY,
@@ -30,11 +32,17 @@ import {
export const action: ActionFunction = async ({ request, url }) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: frontPageSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
try {
switch (data._action) {
case "JOIN_QUEUE": {
@@ -140,13 +148,15 @@ export const action: ActionFunction = async ({ request, url }) => {
"Friend code already set",
);
const friendCode = normalizeFriendCode(data.friendCode);
const isTakenFriendCode = (
await UserRepository.findAllCurrentFriendCodes()
).has(data.friendCode);
).has(friendCode);
await UserRepository.insertFriendCode({
userId: user.id,
friendCode: data.friendCode,
friendCode,
submitterUserId: user.id,
});

View File

@@ -81,10 +81,6 @@
height: 24px;
}
.noteTextarea {
height: 4rem !important;
}
.futureMatchMode {
border-radius: 100%;
background-color: var(--color-bg);

View File

@@ -20,6 +20,7 @@ import {
UserCard,
useUserCardData,
} from "~/features/user-card/components/UserCard";
import { SendouForm } from "~/form/SendouForm";
import { languagesUnified } from "~/modules/i18n/config";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
@@ -34,7 +35,8 @@ import type {
SQGroupMember,
SQOwnGroup,
} from "../core/SendouQ.server";
import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
import { FULL_GROUP_SIZE } from "../q-constants";
import { updateGroupNoteSchema } from "../q-schemas";
import { resolveFutureMatchModes } from "../q-utils";
import styles from "./GroupCard.module.css";
@@ -364,10 +366,6 @@ function MemberNote({
setEditing(true);
};
React.useEffect(() => {
setEditing(false);
}, [note]);
if (editing) {
return (
<AddPrivateNoteForm note={note} stopEditing={() => setEditing(false)} />
@@ -408,31 +406,18 @@ function AddPrivateNoteForm({
note?: string | null;
stopEditing: () => void;
}) {
const fetcher = useFetcher();
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation(["common"]);
const [value, setValue] = React.useState(note ?? "");
const newValueLegal = value.length <= SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH;
React.useEffect(() => {
if (!textareaRef.current) return;
textareaRef.current.focus();
textareaRef.current.selectionStart = textareaRef.current.selectionEnd =
textareaRef.current.value.length;
}, []);
return (
<fetcher.Form method="post" action={SENDOUQ_LOOKING_PAGE}>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
rows={2}
className={`${styles.noteTextarea} mt-1`}
name="value"
ref={textareaRef}
/>
<div className="stack horizontal justify-between">
<SendouForm
schema={updateGroupNoteSchema}
action={SENDOUQ_LOOKING_PAGE}
defaultValues={{ value: note ?? "" }}
className="stack sm mt-1"
submitButtonText={t("common:actions.save")}
submitButtonVariant="minimal"
submitButtonSize="miniscule"
secondarySubmit={
<SendouButton
variant="minimal-destructive"
size="miniscule"
@@ -440,21 +425,11 @@ function AddPrivateNoteForm({
>
{t("common:actions.cancel")}
</SendouButton>
{newValueLegal ? (
<SubmitButton
_action="UPDATE_NOTE"
variant="minimal"
size="miniscule"
>
{t("common:actions.save")}
</SubmitButton>
) : (
<span className="text-warning text-xxs font-semi-bold">
{value.length}/{SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH}
</span>
)}
</div>
</fetcher.Form>
}
onSuccess={stopEditing}
>
{({ FormField }) => <FormField name="value" autoFocus />}
</SendouForm>
);
}

View File

@@ -6,10 +6,12 @@ export const SENDOUQ = {
PRIVATE_USER_NOTE_MAX_LENGTH: 280,
} as const;
export const FRIEND_CODE_REGEXP_PATTERN =
"^(SW-)?[0-9]{4}-?[0-9]{4}-?[0-9]{4}$";
const FRIEND_CODE_REGEXP_PATTERN = "^(SW-)?[0-9]{4}-?[0-9]{4}-?[0-9]{4}$";
export const FRIEND_CODE_REGEXP = new RegExp(FRIEND_CODE_REGEXP_PATTERN);
/** Length of a friend code with the optional "SW-" prefix included */
export const FRIEND_CODE_MAX_LENGTH = 17;
export const FULL_GROUP_SIZE = 4;
export const SENDOUQ_BEST_OF = 7;

View File

@@ -1,14 +1,6 @@
import { z } from "zod";
import {
_action,
deduplicate,
falsyToNull,
friendCode,
id,
modeShort,
stageId,
} from "~/utils/zod";
import { SENDOUQ } from "./q-constants";
import { _action, deduplicate, id, modeShort, stageId } from "~/utils/zod";
import { addFriendCodeSchema, updateGroupNoteSchema } from "./q-schemas";
export const frontPageSchema = z.union([
z.object({
@@ -18,10 +10,7 @@ export const frontPageSchema = z.union([
z.object({
_action: _action("JOIN_TEAM"),
}),
z.object({
_action: _action("ADD_FRIEND_CODE"),
friendCode,
}),
addFriendCodeSchema,
]);
export const preparingSchema = z.union([
@@ -73,13 +62,7 @@ export const lookingSchema = z.union([
z.object({
_action: _action("REFRESH_GROUP"),
}),
z.object({
_action: _action("UPDATE_NOTE"),
value: z.preprocess(
falsyToNull,
z.string().max(SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH).nullable(),
),
}),
updateGroupNoteSchema,
]);
export const weaponUsageSearchParamsSchema = z.object({

View File

@@ -0,0 +1,35 @@
import { z } from "zod";
import { stringConstant, textAreaOptional, textField } from "~/form/fields";
import {
FRIEND_CODE_MAX_LENGTH,
FRIEND_CODE_REGEXP,
SENDOUQ,
} from "./q-constants";
export const updateGroupNoteSchema = z.object({
_action: stringConstant("UPDATE_NOTE"),
value: textAreaOptional({
label: "labels.note",
maxLength: SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH,
}),
});
/**
* Friend code as the user typed it, with the "SW-" prefix and dashes optional.
* Pass through `normalizeFriendCode` before storing it.
*/
export const friendCodeField = textField({
label: "labels.friendCode",
maxLength: FRIEND_CODE_MAX_LENGTH,
leftAddon: "SW-",
placeholder: "placeholders.friendCode",
regExp: {
pattern: FRIEND_CODE_REGEXP,
message: "forms:errors.invalidFriendCode",
},
});
export const addFriendCodeSchema = z.object({
_action: stringConstant("ADD_FRIEND_CODE"),
friendCode: friendCodeField,
});

View File

@@ -3,20 +3,27 @@ import { requireUser } from "~/features/auth/core/user.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import { cancelActiveGroupLikes } from "~/features/sendouq/core/likes.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import { isSupporter } from "~/modules/permissions/utils";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
import { errorToast, parseRequestPayload } from "~/utils/remix.server";
import { errorToast } from "~/utils/remix.server";
import { toDBBoolean } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { settingsActionSchema } from "../settings-schemas.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: settingsActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
switch (data._action) {
case "UPDATE_CUSTOM_THEME": {
if (!isSupporter(user)) {

View File

@@ -18,7 +18,7 @@ export function LocaleTab() {
defaultValues={{
newValue: user.preferences.clockFormat ?? "auto",
}}
autoSubmit
mode="autoSubmit"
revalidateRoot
fullWidth
>

View File

@@ -28,7 +28,7 @@ export function PreferencesTab() {
defaultValues={{
newValue: user.preferences.disableBuildAbilitySorting ?? false,
}}
autoSubmit
mode="autoSubmit"
revalidateRoot
fullWidth
>
@@ -40,7 +40,7 @@ export function PreferencesTab() {
newValue:
user.preferences.disallowScrimPickupsFromUntrusted ?? false,
}}
autoSubmit
mode="autoSubmit"
revalidateRoot
fullWidth
>
@@ -51,7 +51,7 @@ export function PreferencesTab() {
defaultValues={{
newValue: user.preferences.spoilerFreeMode ?? false,
}}
autoSubmit
mode="autoSubmit"
revalidateRoot
fullWidth
>

View File

@@ -1,10 +1,10 @@
import { z } from "zod";
import { customField, select, stringConstant, toggle } from "~/form/fields";
import { hidden, select, stringConstant, toggle } from "~/form/fields";
import { themeInputSchema } from "~/utils/zod";
const customThemeSchema = z.object({
_action: stringConstant("UPDATE_CUSTOM_THEME"),
newValue: customField({ initialValue: null }, themeInputSchema.nullable()),
newValue: hidden(themeInputSchema.nullable(), null),
});
export const clockFormatSchema = z.object({

View File

@@ -14,11 +14,12 @@ import {
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import {
type FriendActivityType,
isLiveFriendActivity,
isInProgressFriendActivity,
} from "~/features/friends/friends-constants";
import {
type FriendActivity,
resolveFriendActivity,
resolveSendouQMatchStreams,
} from "~/features/friends/friends-utils.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
@@ -60,6 +61,7 @@ export type SidebarFriend = {
activityType: FriendActivityType | null;
matchId: number | null;
tournamentId: number | null;
streamUrl: string | null;
};
const MAX_EVENTS_VISIBLE = 5;
@@ -86,12 +88,14 @@ export async function resolveSidebarData(userId: number | null) {
friendsWithActivity,
savedTournaments,
incomingFriendRequestIds,
streamedSendouQMatches,
] = await Promise.all([
ShowcaseTournaments.categorizedTournamentsByUserId(userId),
ScrimPostRepository.findUserScrims(userId),
FriendRepository.findByUserIdWithActivity(userId),
SavedCalendarEventRepository.findAllUpcomingByUserId(userId),
FriendRepository.findPendingReceivedRequestIds(userId),
resolveSendouQMatchStreams(),
]);
const seenTournamentIds = new Set<number>();
@@ -119,7 +123,7 @@ export async function resolveSidebarData(userId: number | null) {
.sort((a, b) => a.startsAt - b.startsAt)
.slice(0, MAX_EVENTS_VISIBLE);
const friends = resolveFriends(friendsWithActivity);
const friends = resolveFriends(friendsWithActivity, streamedSendouQMatches);
const savedTournamentIds = savedTournaments.map((t) => t.id);
@@ -277,7 +281,20 @@ type FriendWithActivity = Awaited<
ReturnType<typeof FriendRepository.findByUserIdWithActivity>
>[number];
function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
function resolveFriends(
friendsWithActivity: FriendWithActivity[],
streamedSendouQMatches: ReadonlyMap<number, string>,
) {
const activityForRow = (row: FriendWithActivity) =>
resolveFriendActivity({
friendId: row.id,
tournamentId: row.tournamentId,
tournamentName: row.tournamentName,
teamMemberCount: row.teamMemberCount,
tournamentMinTeamSize: row.tournamentMinTeamSize,
sendouQMatchStreams: streamedSendouQMatches,
});
const unique = R.uniqueBy(friendsWithActivity, (f) => f.id);
const friendRows = unique.filter((f) => f.friendshipId !== null);
const teamMemberRows = unique.filter((f) => f.friendshipId === null);
@@ -297,7 +314,7 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
const sidebarFriend = rowToSidebarFriend(friend, activity);
if (isLiveFriendActivity(activity.type)) {
if (isInProgressFriendActivity(activity.type)) {
activeFriends.push(sidebarFriend);
} else if (activity.type === "SENDOUQ") {
sendouqFriends.push(sidebarFriend);
@@ -360,16 +377,6 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
return result.slice(0, MAX_FRIENDS_VISIBLE);
}
function activityForRow(row: FriendWithActivity): FriendActivity {
return resolveFriendActivity({
friendId: row.id,
tournamentId: row.tournamentId,
tournamentName: row.tournamentName,
teamMemberCount: row.teamMemberCount,
tournamentMinTeamSize: row.tournamentMinTeamSize,
});
}
function rowToSidebarFriend(
row: FriendWithActivity,
activity: FriendActivity | null,
@@ -386,6 +393,7 @@ function rowToSidebarFriend(
activityType: activity?.type ?? null,
matchId: activity?.matchId ?? null,
tournamentId: activity?.tournamentId ?? row.tournamentId,
streamUrl: activity?.streamUrl ?? null,
};
}

View File

@@ -8,8 +8,8 @@ import {
selectOptional,
stringConstant,
textAreaOptional,
textField,
textFieldOptional,
textFieldRequired,
toggle,
} from "~/form/fields";
import { mySlugify } from "~/utils/urls";
@@ -26,7 +26,7 @@ const teamNameValidate = {
} as const;
export const createTeamSchema = z.object({
name: textFieldRequired({
name: textField({
label: "labels.name",
minLength: TEAM.NAME_MIN_LENGTH,
maxLength: TEAM.NAME_MAX_LENGTH,
@@ -36,7 +36,7 @@ export const createTeamSchema = z.object({
export const editTeamFormSchema = z.object({
_action: stringConstant("EDIT"),
name: textFieldRequired({
name: textField({
label: "labels.name",
bottomText: "bottomTexts.name",
minLength: TEAM.NAME_MIN_LENGTH,

View File

@@ -1,19 +1,13 @@
import { z } from "zod";
import { TOURNAMENT_STAFF_ROLES } from "~/features/tournament/tournament-constants";
import {
array,
fieldset,
select,
textFieldRequired,
userSearch,
} from "~/form/fields";
import { array, fieldset, select, textField, userSearch } from "~/form/fields";
export const adminStreamFormSchema = z.object({
castTwitchAccounts: array({
label: "labels.castTwitchAccounts",
bottomText: "bottomTexts.castTwitchAccounts",
max: 5,
field: textFieldRequired({
field: textField({
maxLength: 100,
placeholder: "placeholders.castTwitchAccounts",
}),

View File

@@ -1392,6 +1392,14 @@ describe("bracketIdxsForStandings", () => {
),
).toEqual([0]); // missing 1 because it's underground when SE is the source
});
it("does not treat a bracket as intermediate just because an underground bracket sources from it", () => {
expect(
Progression.bracketIdxsForStandings(
progressions.swissToTwoSingleEliminationsWithUnderground,
),
).toEqual([1, 2, 0]); // missing 3 because it's underground
});
});
describe("startingBrackets", () => {

View File

@@ -931,11 +931,18 @@ export function bracketIdxsForStandings(progression: ParsedBracket[]) {
const bracketsToConsider = bracketsReachableFrom(0, progression);
const withoutIntermediateBrackets = bracketsToConsider.filter(
(bracket, bracketIdx) => {
(bracketIdx) => {
if (bracketIdx === 0) return true;
// underground brackets don't make their source bracket an intermediate one
const undergrounds = new Set(
undergroundBracketIdxs(bracketIdx, progression),
);
return progression.every(
(b) => !b.sources?.some((s) => s.bracketIdx === bracket),
(b, idx) =>
undergrounds.has(idx) ||
!b.sources?.some((s) => s.bracketIdx === bracketIdx),
);
},
);
@@ -1033,6 +1040,23 @@ export function destinationsFromBracketIdx(
return destinations;
}
/**
* Returns the indexes of the underground brackets sourced from the given bracket.
* An underground bracket is one that takes teams eliminated from its source bracket (negative placements).
*/
export function undergroundBracketIdxs(
bracketIdx: number,
progression: ParsedBracket[],
): number[] {
return destinationsFromBracketIdx(bracketIdx, progression).filter((idx) =>
progression[idx].sources?.some(
(source) =>
source.bracketIdx === bracketIdx &&
source.placements.some((placement) => placement < 0),
),
);
}
export function destinationByPlacement({
sourceBracketIdx,
placement,

View File

@@ -1049,6 +1049,7 @@ export class Tournament {
type: "MATCH",
matchId: match.id,
opponent: otherTeam.name,
opponentId: otherTeam.id,
} as const;
}

View File

@@ -336,4 +336,46 @@ export const progressions = {
],
},
],
swissToTwoSingleEliminationsWithUnderground: [
{
...DEFAULT_PROGRESSION_ARGS,
type: "swiss",
settings: {
groupCount: 1,
},
},
{
...DEFAULT_PROGRESSION_ARGS,
type: "single_elimination",
name: "Alpha",
sources: [
{
bracketIdx: 0,
placements: [1, 2, 3, 4, 5, 6, 7, 8],
},
],
},
{
...DEFAULT_PROGRESSION_ARGS,
type: "single_elimination",
name: "Beta",
sources: [
{
bracketIdx: 0,
placements: [9, 10, 11, 12, 13, 14, 15, 16],
},
],
},
{
...DEFAULT_PROGRESSION_ARGS,
type: "single_elimination",
name: "Alpha UG",
sources: [
{
bracketIdx: 1,
placements: [-1],
},
],
},
],
} satisfies Record<string, Progression.ParsedBracket[]>;

View File

@@ -7,11 +7,8 @@ import {
clearTournamentDataCache,
tournamentFromDBCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import {
errorToastIfFalsy,
parseParams,
parseRequestPayload,
} from "~/utils/remix.server";
import { parseFormData } from "~/form/parse.server";
import { errorToastIfFalsy, parseParams } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { idObject } from "~/utils/zod";
import * as TournamentLFGRepository from "../TournamentLFGRepository.server";
@@ -22,11 +19,17 @@ import { setPickupChatMetadata } from "../tournament-lfg-utils.server";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = requireUser();
const { id: tournamentId } = parseParams({ params, schema: idObject });
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: lookingSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const findOwnGroup = async () => {
const groups =
await TournamentLFGRepository.findLookingTeamsByTournamentId(

View File

@@ -211,17 +211,19 @@ export function TournamentMatchBanner({
/>
</MatchBanner>
) : null}
<MatchBannerBottomRow
games={resolveBannerGames({ data })}
activeRosters={
opponentOne?.id && opponentTwo?.id
? {
alpha: activeRosterByTeamId(opponentOne.id),
bravo: activeRosterByTeamId(opponentTwo.id),
}
: null
}
/>
{data.matchIsOver ? null : (
<MatchBannerBottomRow
games={resolveBannerGames({ data })}
activeRosters={
opponentOne?.id && opponentTwo?.id
? {
alpha: activeRosterByTeamId(opponentOne.id),
bravo: activeRosterByTeamId(opponentTwo.id),
}
: null
}
/>
)}
</MatchBannerContainer>
);
}
@@ -245,6 +247,12 @@ function TournamentMatchBannerTopRow({
const startedAt = databaseTimestampToDate(data.match.startedAt);
const totalMinutes = differenceInMinutes(currentTime, startedAt);
const lastResultCreatedAt = data.results.at(-1)?.createdAt;
const endedAt =
typeof lastResultCreatedAt === "number"
? databaseTimestampToDate(lastResultCreatedAt)
: null;
const currentMinutes = resolveCurrentMinutes({
data,
tournament,
@@ -262,7 +270,7 @@ function TournamentMatchBannerTopRow({
}}
>
{data.matchIsOver ? (
<MatchBannerStartedAt time={startedAt} />
<MatchBannerStartedAt time={startedAt} endTime={endedAt} />
) : (
<MatchBannerTimer time={{ currentMinutes, totalMinutes }} />
)}
@@ -552,8 +560,6 @@ function resolveBannerGames({
mode: map.mode as ModeShort | null,
})) ?? [];
if (data.matchIsOver) return playedAndScheduled;
const placeholderCount = Math.max(
0,
data.match.roundMaps.count - playedAndScheduled.length,

View File

@@ -5,7 +5,6 @@ import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tou
import { parseFormDataWithImages } from "~/form/parse.server";
import { getServerTFunction } from "~/modules/i18n/i18next.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { actionError } from "~/utils/remix.server";
import { tournamentOrganizationPage } from "~/utils/urls";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { organizationEditFormSchema } from "../tournament-organization-schemas";
@@ -35,10 +34,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
(member) => member.userId === user.id && member.role === "ADMIN",
)
) {
return actionError<typeof organizationEditFormSchema>({
msg: t("org:edit.form.errors.noUnadmin"),
field: "members.root",
});
return {
fieldErrors: { members: t("org:edit.form.errors.noUnadmin") },
};
}
const socials = data.socials.filter((s) => s.length > 0);

View File

@@ -1,6 +1,7 @@
import { isFuture } from "date-fns";
import { type ActionFunctionArgs, redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { parseFormData } from "~/form/parse.server";
import {
requirePermission,
requireRole,
@@ -10,11 +11,7 @@ import {
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { logger } from "~/utils/logger";
import {
errorToast,
errorToastIfFalsy,
parseRequestPayload,
} from "~/utils/remix.server";
import { errorToast, errorToastIfFalsy } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { TOURNAMENT_ORGANIZATION } from "../tournament-organization-constants";
@@ -24,11 +21,17 @@ import { organizationFromParams } from "../tournament-organization-utils.server"
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = requireUser();
const organization = await organizationFromParams(params);
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: orgPageActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
switch (data._action) {
case "BAN_USER": {
requirePermission(organization, "BAN");

View File

@@ -285,7 +285,7 @@ function AdminControls() {
defaultValues={{
isEstablished: Boolean(data.organization.isEstablished),
}}
autoSubmit
mode="autoSubmit"
>
{({ FormField }) => <FormField name="isEstablished" />}
</SendouForm>

View File

@@ -12,15 +12,15 @@ import {
select,
stringConstant,
textAreaOptional,
textField,
textFieldOptional,
textFieldRequired,
toggle,
userSearch,
} from "~/form/fields";
import { mySlugify } from "~/utils/urls";
import { _action, id } from "~/utils/zod";
const orgNameField = textFieldRequired({
const orgNameField = textField({
label: "labels.name",
minLength: 2,
maxLength: 64,
@@ -65,14 +65,14 @@ export const organizationEditFormSchema = z.object({
socials: array({
label: "labels.orgSocialLinks",
max: 10,
field: textFieldRequired({ validate: "url", maxLength: 100 }),
field: textField({ validate: "url", maxLength: 100 }),
}),
series: array({
label: "labels.orgSeries",
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({
name: textField({
label: "labels.orgSeriesName",
minLength: 1,
maxLength: 32,

View File

@@ -1,11 +1,11 @@
import { Bookmark, BookmarkCheck, Share2 } from "lucide-react";
import { Bookmark, BookmarkCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useFetcher } from "react-router";
import * as R from "remeda";
import { Avatar } from "~/components/Avatar";
import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { DiscordIcon } from "~/components/icons/Discord";
import { ShareUrlButton } from "~/components/ShareUrlButton";
import TimePopover from "~/components/TimePopover";
import { useUser } from "~/features/auth/core/user";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
@@ -93,7 +93,9 @@ export function TournamentHeaderActions({
aria-label="Discord"
/>
) : null}
<ShareTournamentButton tournament={tournament} />
<ShareUrlButton
url={`${SENDOU_INK_BASE_URL}${tournamentPage(tournament.ctx.id)}`}
/>
</div>
);
}
@@ -167,43 +169,3 @@ function OrganizerLink({ tournament }: { tournament: Tournament }) {
</Link>
);
}
function ShareTournamentButton({ tournament }: { tournament: Tournament }) {
const { t } = useTranslation(["common"]);
const url = `${SENDOU_INK_BASE_URL}${tournamentPage(tournament.ctx.id)}`;
const handleShare = () => {
navigator.share({ url });
};
if (
typeof navigator !== "undefined" &&
typeof navigator.share === "function"
) {
return (
<SendouButton
variant="outlined"
size="small"
shape="circle"
icon={<Share2 />}
onPress={handleShare}
aria-label={t("common:actions.share")}
/>
);
}
return (
<CopyToClipboardPopover
url={url}
trigger={
<SendouButton
variant="outlined"
size="small"
shape="circle"
icon={<Share2 />}
aria-label={t("common:actions.share")}
/>
}
/>
);
}

View File

@@ -80,6 +80,77 @@ describe("tournamentStandings", () => {
expect(a.standings.map((s) => s.placement)).toEqual([1, 2]);
expect(b.standings.map((s) => s.placement)).toEqual([1, 2]);
});
it("breaks ties of a bracket with the results of its underground bracket", () => {
const tournament = singleEliminationWithUndergroundTournament();
const result = tournamentStandings(tournament);
invariant(result.type === "single");
// teams 5-8 all lost the quarterfinals so they are tied in the main bracket,
// the underground bracket (won by 8, then 7, 6, 5) decides their order
expect(result.standings.map((s) => s.team.id)).toEqual([
1, 2, 3, 4, 8, 7, 6, 5,
]);
expect(result.standings.map((s) => s.placement)).toEqual([
1, 2, 3, 4, 5, 6, 7, 8,
]);
});
it("keeps teams that skipped the underground bracket tied below those who played it", () => {
const tournament = singleEliminationWithUndergroundTournament({
undergroundSeeding: [7, 8],
});
const result = tournamentStandings(tournament);
invariant(result.type === "single");
expect(result.standings.map((s) => s.team.id)).toEqual([
1, 2, 3, 4, 8, 7, 5, 6,
]);
expect(result.standings.map((s) => s.placement)).toEqual([
1, 2, 3, 4, 5, 6, 7, 7,
]);
});
it("does not break ties with an underground bracket that is still in progress", () => {
// only the semi-finals of the underground bracket have been played so the two teams
// still alive there have no placement yet
const tournament = singleEliminationWithUndergroundTournament({
undergroundConsolationFinal: false,
undergroundMatchesPlayed: 2,
});
const result = tournamentStandings(tournament);
invariant(result.type === "single");
// teams 5-8 keep the order & tied placement they have in the main bracket,
// the teams eliminated from the underground bracket are not sorted above those still in it
expect(result.standings.map((s) => s.team.id)).toEqual([
1, 2, 3, 4, 8, 5, 7, 6,
]);
expect(result.standings.map((s) => s.placement)).toEqual([
1, 2, 3, 4, 5, 5, 5, 5,
]);
});
it("does not break ties with an underground bracket that was never started", () => {
// an underground bracket set in the progression can be skipped altogether
const tournament = singleEliminationWithUndergroundTournament({
undergroundStarted: false,
});
expect(tournament.bracketByIdx(1)?.preview).toBe(true);
const result = tournamentStandings(tournament);
invariant(result.type === "single");
expect(result.standings.map((s) => s.team.id)).toEqual([
1, 2, 3, 4, 8, 5, 7, 6,
]);
expect(result.standings.map((s) => s.placement)).toEqual([
1, 2, 3, 4, 5, 5, 5, 5,
]);
});
});
describe("reNumberPlacements", () => {
@@ -216,6 +287,68 @@ function singleEliminationTournament() {
});
}
function singleEliminationWithUndergroundTournament({
undergroundSeeding = [5, 6, 7, 8],
undergroundConsolationFinal = undergroundSeeding.length > 2,
undergroundMatchesPlayed,
undergroundStarted = true,
}: {
undergroundSeeding?: number[];
undergroundConsolationFinal?: boolean;
undergroundMatchesPlayed?: number;
undergroundStarted?: boolean;
} = {}) {
const mainBracket = playOut(
createResolved({
type: "single_elimination",
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
settings: { consolationFinal: true },
}),
(one, two) => one < two,
);
const data = undergroundStarted
? mergeStages(
mainBracket,
playOut(
createResolved({
type: "single_elimination",
seeding: undergroundSeeding,
settings: { consolationFinal: undergroundConsolationFinal },
}),
(one, two) => one > two,
undergroundMatchesPlayed,
),
)
: mainBracket;
return testTournament({
ctx: {
settings: {
bracketProgression: [
{
type: "single_elimination",
name: "Main Bracket",
requiresCheckIn: false,
settings: { thirdPlaceMatch: true },
},
{
type: "single_elimination",
name: "Underground",
requiresCheckIn: false,
settings: { thirdPlaceMatch: true },
sources: [{ bracketIdx: 0, placements: [-1] }],
},
],
},
teams: [1, 2, 3, 4, 5, 6, 7, 8].map((id) =>
tournamentCtxTeam(id, { startingBracketIdx: 0, seed: id }),
),
},
data,
});
}
function abDivisionsTournament() {
let data = createResolved({
type: "round_robin",
@@ -273,9 +406,22 @@ function abDivisionsTournament() {
/** Plays every match of the bracket data, the lower team id always winning. */
function playOutLowerIdWins(data: BracketData) {
let played = data;
return playOut(data, (one, two) => one < two);
}
while (true) {
/**
* Plays every match of the bracket data, `opponent1Wins` deciding each match by team id.
* `maxMatches` can be given to leave the bracket in progress.
*/
function playOut(
data: BracketData,
opponent1Wins: (opponent1Id: number, opponent2Id: number) => boolean,
maxMatches = Number.POSITIVE_INFINITY,
) {
let played = data;
let playedCount = 0;
while (playedCount < maxMatches) {
const pending = played.match.find(
(match) =>
typeof match.opponent1?.id === "number" &&
@@ -284,12 +430,16 @@ function playOutLowerIdWins(data: BracketData) {
);
if (!pending) break;
const winnerIsOpp1 = pending.opponent1!.id! < pending.opponent2!.id!;
const winnerIsOpp1 = opponent1Wins(
pending.opponent1!.id as number,
pending.opponent2!.id as number,
);
played = Engine.reportResult(played, {
matchId: pending.id,
scores: [winnerIsOpp1 ? 2 : 0, winnerIsOpp1 ? 0 : 2],
winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2",
}).data;
playedCount++;
}
return played;

View File

@@ -257,7 +257,11 @@ function tournamentStandingsForBracket(
const standings = standingsToMergeable({
alreadyIncludedTeamIds,
standings: bracket.standings,
standings: tiebrokenByUndergroundBrackets({
tournament,
bracketIdx: idx,
standings: bracket.standings,
}),
teamsAboveFromAnotherBracketsCount: alreadyIncludedTeamIds.size,
});
result.push(...standings);
@@ -273,6 +277,85 @@ function tournamentStandingsForBracket(
return result;
}
/**
* Underground brackets are left out of the standings but the teams playing them are tied in their source
* bracket (e.g. everyone who lost the quarterfinals shares the same placement), so their underground run
* decides the order within each such tie. Teams that skipped the underground bracket stay tied last.
*
* An underground bracket that is still in progress is ignored, as the teams still alive in it have no
* placement yet and would sort below the teams it already eliminated.
*/
function tiebrokenByUndergroundBrackets({
tournament,
bracketIdx,
standings,
}: {
tournament: Tournament;
bracketIdx: number;
standings: Standing[];
}): Standing[] {
const undergroundPlacements = new Map<number, number>();
for (const undergroundIdx of Progression.undergroundBracketIdxs(
bracketIdx,
tournament.ctx.settings.bracketProgression,
)) {
const underground = tournament.bracketByIdx(undergroundIdx);
if (!underground?.everyMatchOver) continue;
for (const standing of underground.standings) {
if (undergroundPlacements.has(standing.team.id)) continue;
undergroundPlacements.set(standing.team.id, standing.placement);
}
}
if (undergroundPlacements.size === 0) return standings;
const result: Standing[] = [];
for (const tied of groupedByPlacement(standings)) {
const sorted = R.sortBy(
tied,
(standing) =>
undergroundPlacements.get(standing.team.id) ?? Number.POSITIVE_INFINITY,
);
let placement = tied[0].placement;
let previousUndergroundPlacement: number | null = null;
for (const [index, standing] of sorted.entries()) {
const undergroundPlacement =
undergroundPlacements.get(standing.team.id) ?? null;
if (index > 0 && undergroundPlacement !== previousUndergroundPlacement) {
placement = tied[0].placement + index;
}
previousUndergroundPlacement = undergroundPlacement;
result.push({ ...standing, placement });
}
}
return result;
}
function groupedByPlacement(standings: Standing[]): Standing[][] {
const result: Standing[][] = [];
for (const standing of standings) {
const previous = result.at(-1);
if (previous && previous[0].placement === standing.placement) {
previous.push(standing);
} else {
result.push([standing]);
}
}
return result;
}
function standingsToMergeable<
T extends { team: { id: number }; placement: number },
>({

View File

@@ -1,21 +1,17 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import type { Tables } from "~/db/tables";
import { SENDOUQ } from "~/features/sendouq/q-constants";
import { preferenceEmojiUrl, userCardNotePage } from "~/utils/urls";
import { SendouForm } from "~/form/SendouForm";
import { userCardNotePage } from "~/utils/urls";
import { userCardNoteSaveSchema } from "../user-card-schemas";
type PrivateNote = Pick<Tables["PrivateUserNote"], "text" | "sentiment">;
/**
* Modal for adding/editing the viewer's private note about a user, posting to the
* `/user-card/:id/note` resource route. Closes once the fetcher settles (save succeeds →
* automatic revalidation refreshes the card). Clearing the text with a neutral sentiment and saving
* deletes the note (handled by the route). Rendered wherever a `UserCard` lives.
* `/user-card/:id/note` resource route. Closes once the save succeeds (automatic revalidation
* refreshes the card). Clearing the text with a neutral sentiment and saving deletes the note
* (handled by the route). Rendered wherever a `UserCard` lives.
*/
export function AddPrivateNoteDialog({
userId,
@@ -29,119 +25,29 @@ export function AddPrivateNoteDialog({
onClose: () => void;
}) {
const { t } = useTranslation(["q", "common"]);
const fetcher = useFetcher();
const wasSubmittingRef = React.useRef(false);
React.useEffect(() => {
if (fetcher.state !== "idle") {
wasSubmittingRef.current = true;
} else if (wasSubmittingRef.current) {
wasSubmittingRef.current = false;
if ((fetcher.data as { ok?: boolean } | undefined)?.ok) {
onClose();
}
}
}, [fetcher.state, fetcher.data, onClose]);
return (
<SendouDialog
heading={t("q:privateNote.header", { name: username })}
onClose={onClose}
>
<fetcher.Form
method="post"
<SendouForm
schema={userCardNoteSaveSchema}
action={userCardNotePage(userId)}
className="stack md"
defaultValues={{
comment: note?.text ?? "",
sentiment: note?.sentiment ?? "NEUTRAL",
}}
submitButtonText={t("common:actions.save")}
onSuccess={onClose}
>
<Textarea initialValue={note?.text} />
<Sentiment initialValue={note?.sentiment} />
<div className="stack items-center mt-2">
<SubmitButton _action="SAVE" state={fetcher.state}>
{t("common:actions.save")}
</SubmitButton>
</div>
</fetcher.Form>
{({ FormField }) => (
<>
<FormField name="comment" />
<FormField name="sentiment" />
</>
)}
</SendouForm>
</SendouDialog>
);
}
function Sentiment({
initialValue,
}: {
initialValue?: Tables["PrivateUserNote"]["sentiment"];
}) {
const { t } = useTranslation(["q"]);
const [sentiment, setSentiment] = React.useState<
Tables["PrivateUserNote"]["sentiment"]
>(initialValue ?? "NEUTRAL");
return (
<div>
<Label>{t("q:privateNote.sentiment.header")}</Label>
<input type="hidden" name="sentiment" value={sentiment} />
<div className="stack xs my-2">
{(["POSITIVE", "NEUTRAL", "NEGATIVE"] as const).map(
(sentimentRadio) => {
return (
<div
key={sentimentRadio}
className="stack horizontal xs items-center"
>
<input
type="radio"
id={sentimentRadio}
checked={sentimentRadio === sentiment}
onChange={() => setSentiment(sentimentRadio)}
/>
<label
htmlFor={sentimentRadio}
className="mb-0 stack horizontal xs"
>
<img
src={preferenceEmojiUrl(
sentimentRadio === "POSITIVE"
? "PREFER"
: sentimentRadio === "NEGATIVE"
? "AVOID"
: undefined,
)}
alt=""
width={18}
/>
{t(`q:privateNote.sentiment.${sentimentRadio}`)}
</label>
</div>
);
},
)}
</div>
<FormMessage type="info">{t("q:privateNote.sentiment.info")}</FormMessage>
</div>
);
}
function Textarea({ initialValue }: { initialValue?: string | null }) {
const { t } = useTranslation(["q"]);
const [value, setValue] = React.useState(initialValue ?? "");
return (
<div className="stack">
<Label
htmlFor="comment"
valueLimits={{
current: value.length,
max: SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH,
}}
>
{t("q:privateNote.comment.header")}
</Label>
<textarea
id="comment"
name="comment"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH}
/>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import { parseParams, parseRequestPayload } from "~/utils/remix.server";
import { parseFormData } from "~/form/parse.server";
import { parseParams } from "~/utils/remix.server";
import {
userCardNoteParamsSchema,
userCardNoteSchema,
@@ -14,11 +15,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
params,
schema: userCardNoteParamsSchema,
}).id;
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: userCardNoteSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const isEmptySave =
data._action === "SAVE" &&
data.comment === null &&
@@ -26,7 +33,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
if (data._action === "DELETE" || isEmptySave) {
await PrivateUserNoteRepository.deleteOwnNoteById(targetId);
return { ok: true };
return null;
}
await PrivateUserNoteRepository.upsertOwnNote({
@@ -35,5 +42,5 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
text: data.comment,
});
return { ok: true };
return null;
};

View File

@@ -4,12 +4,15 @@ import {
customField,
image,
numberFieldOptional,
radioGroup,
select,
stageSelect,
stringConstant,
textAreaOptional,
toggle,
} from "~/form/fields";
import { _action, falsyToNull, id } from "~/utils/zod";
import { preferenceEmojiUrl } from "~/utils/urls";
import { _action, id } from "~/utils/zod";
import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants";
import { USER_CARD } from "./user-card-constants";
@@ -51,15 +54,37 @@ export const updateUserCardSchema = z.object({
hideDiv: toggle({ label: "labels.hideDiv" }),
});
export const userCardNoteSchema = z.union([
z.object({
_action: _action("SAVE"),
comment: z.preprocess(
falsyToNull,
z.string().max(SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH).nullable(),
),
sentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE"]),
export const userCardNoteSaveSchema = z.object({
_action: stringConstant("SAVE"),
comment: textAreaOptional({
label: "labels.comment",
maxLength: SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH,
}),
sentiment: radioGroup({
label: "labels.sentiment",
bottomText: "bottomTexts.sentiment",
items: [
{
value: "POSITIVE",
label: "options.sentiment.POSITIVE",
imgSrc: preferenceEmojiUrl("PREFER"),
},
{
value: "NEUTRAL",
label: "options.sentiment.NEUTRAL",
imgSrc: preferenceEmojiUrl(),
},
{
value: "NEGATIVE",
label: "options.sentiment.NEGATIVE",
imgSrc: preferenceEmojiUrl("AVOID"),
},
],
}),
});
export const userCardNoteSchema = z.union([
userCardNoteSaveSchema,
z.object({
_action: _action("DELETE"),
}),

View File

@@ -3,12 +3,9 @@ import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { adminTabActionSchema } from "~/features/user-page/user-page-schemas";
import { parseFormData } from "~/form/parse.server";
import { requireRole } from "~/modules/permissions/guards.server";
import {
badRequestIfFalsy,
notFoundIfNullish,
parseRequestPayload,
} from "~/utils/remix.server";
import { badRequestIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
export const action = async ({ request, params }: ActionFunctionArgs) => {
@@ -16,11 +13,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
requireRole("STAFF");
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: adminTabActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const user = notFoundIfNullish(
await UserRepository.findLayoutDataByIdentifier(params.identifier!),
);

View File

@@ -4,7 +4,6 @@ import type { Tables } from "~/db/tables";
import { type CustomFieldRenderProps, FormField } from "~/form/FormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import {
CONTROLLERS,
getWidgetFormSchema,
TIMEZONE_OPTIONS,
} from "../core/widgets/widget-form-schemas";
@@ -55,7 +54,7 @@ function WidgetSettingsFormInner({
<SendouForm
schema={schema}
defaultValues={defaultValues}
autoApply
mode="client"
onApply={handleApply}
className="stack md"
>
@@ -91,13 +90,7 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) {
case "links":
return <FormField name="links" />;
case "tier-list":
return (
<FormField name="searchParams">
{(props: CustomFieldRenderProps) => (
<TierListField {...(props as CustomFieldRenderProps<string>)} />
)}
</FormField>
);
return <FormField name="searchParams" />;
case "game-badges":
return (
<FormField name="badgeIds">
@@ -134,21 +127,12 @@ function SensFields() {
const { t } = useTranslation(["user"]);
const { values, setValue, onFieldChange } = useFormFieldContext();
const controller =
(values.controller as (typeof CONTROLLERS)[number]) ?? "s2-pro-con";
const motionSens = (values.motionSens as number | null) ?? null;
const stickSens = (values.stickSens as number | null) ?? null;
const rawSensToString = (sens: number) =>
`${sens > 0 ? "+" : ""}${sens / 10}`;
const handleControllerChange = (
newController: (typeof CONTROLLERS)[number],
) => {
setValue("controller", newController);
onFieldChange?.("controller", newController);
};
const handleMotionSensChange = (sens: number | null) => {
setValue("motionSens", sens);
onFieldChange?.("motionSens", sens);
@@ -161,25 +145,7 @@ function SensFields() {
return (
<div className="stack md">
<div>
<label htmlFor="controller">{t("widgets.forms.controller")}</label>
<select
id="controller"
value={controller}
onChange={(e) =>
handleControllerChange(
e.target.value as (typeof CONTROLLERS)[number],
)
}
className={clsx(styles.sensSelect)}
>
{CONTROLLERS.map((ctrl) => (
<option key={ctrl} value={ctrl}>
{t(`user:controllers.${ctrl}`)}
</option>
))}
</select>
</div>
<FormField name="controller" />
<div className="stack horizontal md">
<div>
@@ -227,41 +193,3 @@ function SensFields() {
</div>
);
}
function TierListField({ value, onChange }: CustomFieldRenderProps<string>) {
const { t } = useTranslation(["user"]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
if (inputValue.includes("/tier-list-maker")) {
try {
const url = new URL(inputValue, "https://sendou.ink");
const extractedSearchParams = url.search.substring(1);
onChange(extractedSearchParams);
return;
} catch {
// not a valid URL, just use the value as-is
}
}
onChange(inputValue);
};
return (
<div>
<label htmlFor="tier-list-searchParams">
{t("widgets.forms.tierListUrl")}
</label>
<div className="input-container">
<div className="input-addon">/tier-list-maker?</div>
<input
className="in-container"
id="tier-list-searchParams"
value={value ?? ""}
onChange={handleChange}
/>
</div>
</div>
);
}

View File

@@ -8,8 +8,8 @@ import {
select,
selectDynamic,
stageSelect,
textAreaRequired,
textFieldRequired,
textArea,
textField,
weaponSelect,
} from "~/form/fields";
import type { SelectOption } from "~/form/types";
@@ -17,34 +17,34 @@ import { GAME_BADGE_IDS } from "~/modules/in-game-lists/game-badge-ids";
import { USER } from "../../user-page-constants";
export const bioSchema = z.object({
bio: textAreaRequired({
bio: textArea({
label: "labels.bio",
maxLength: USER.BIO_MAX_LENGTH,
}),
});
export const bioMdSchema = z.object({
bio: textAreaRequired({
bio: textArea({
label: "labels.bio",
bottomText: "bottomTexts.bioMarkdown" as never,
bottomText: "bottomTexts.bioMarkdown",
maxLength: USER.BIO_MD_MAX_LENGTH,
}),
});
export const xRankPeaksSchema = z.object({
division: select({
label: "labels.division" as never,
label: "labels.division",
items: [
{ value: "both", label: "options.division.both" as never },
{ value: "tentatek", label: "options.division.tentatek" as never },
{ value: "takoroka", label: "options.division.takoroka" as never },
{ value: "both", label: "options.division.both" },
{ value: "tentatek", label: "options.division.tentatek" },
{ value: "takoroka", label: "options.division.takoroka" },
],
}),
});
export const timezoneSchema = z.object({
timezone: selectDynamic({
label: "labels.timezone" as never,
label: "labels.timezone",
}),
});
@@ -55,50 +55,52 @@ export const TIMEZONE_OPTIONS: SelectOption[] = TIMEZONES.map((tz) => ({
export const favoriteStageSchema = z.object({
stageId: stageSelect({
label: "labels.favoriteStage" as never,
label: "labels.favoriteStage",
}),
});
export const peakXpUnverifiedSchema = z.object({
peakXp: numberField({
label: "labels.peakXp" as never,
label: "labels.peakXp",
minLength: 4,
maxLength: 4,
}),
division: select({
label: "labels.division" as never,
label: "labels.division",
items: [
{ value: "tentatek", label: "options.division.tentatek" as never },
{ value: "takoroka", label: "options.division.takoroka" as never },
{ value: "tentatek", label: "options.division.tentatek" },
{ value: "takoroka", label: "options.division.takoroka" },
],
}),
});
export const peakXpWeaponSchema = z.object({
weaponSplId: weaponSelect({
label: "labels.weapon" as never,
label: "labels.weapon",
}),
});
export const CONTROLLERS = [
"s1-pro-con",
"s2-pro-con",
"grip",
"handheld",
] as const;
const CONTROLLERS = ["s1-pro-con", "s2-pro-con", "grip", "handheld"] as const;
export const sensSchema = z.object({
controller: customField({ initialValue: "s2-pro-con" }, z.enum(CONTROLLERS)),
controller: select({
label: "labels.controller",
items: CONTROLLERS.map((controller) => ({
value: controller,
label: `options.controller.${controller}` as const,
})),
initialValue: "s2-pro-con",
}),
motionSens: customField({ initialValue: null }, z.number().nullable()),
stickSens: customField({ initialValue: null }, z.number().nullable()),
});
export const artSchema = z.object({
source: select({
label: "labels.artSource" as never,
label: "labels.artSource",
items: ART_SOURCES.map((source) => ({
value: source,
label: `options.artSource.${source}` as never,
label: `options.artSource.${source}`,
})),
}),
});
@@ -108,7 +110,7 @@ export const linksSchema = z.object({
label: "labels.urls",
min: 1,
max: 10,
field: textFieldRequired({
field: textField({
maxLength: 150,
validate: "url",
}),
@@ -116,10 +118,11 @@ export const linksSchema = z.object({
});
export const tierListSchema = z.object({
searchParams: textFieldRequired({
label: "labels.tierListUrl" as never,
searchParams: textField({
label: "labels.tierListUrl",
leftAddon: "/tier-list-maker?",
maxLength: 500,
transformValue: pastedTierListUrlToSearchParams,
}),
});
@@ -160,3 +163,14 @@ const WIDGET_FORM_SCHEMAS: Record<string, z.ZodObject<z.ZodRawShape>> = {
export function getWidgetFormSchema(widgetId: string) {
return WIDGET_FORM_SCHEMAS[widgetId];
}
/** Lets the user paste a whole tier list maker URL instead of only its query string. */
function pastedTierListUrlToSearchParams(value: string) {
if (!value.includes("/tier-list-maker")) return value;
try {
return new URL(value, "https://sendou.ink").search.substring(1);
} catch {
return value;
}
}

View File

@@ -15,10 +15,10 @@ import {
inGameName,
selectDynamicOptional,
stringConstant,
textArea,
textAreaOptional,
textAreaRequired,
textField,
textFieldOptional,
textFieldRequired,
toggle,
weaponPool,
} from "~/form/fields";
@@ -180,7 +180,7 @@ export const editHighlightsActionSchema = z.object({
export const addModNoteSchema = z.object({
_action: stringConstant("ADD_MOD_NOTE"),
value: textAreaRequired({
value: textArea({
label: "labels.text",
bottomText: "bottomTexts.modNote",
maxLength: USER.MOD_NOTE_MAX_LENGTH,
@@ -318,7 +318,7 @@ export const newBuildBaseSchema = z.object({
},
abilitiesSchema,
),
title: textFieldRequired({
title: textField({
label: "labels.buildTitle",
maxLength: 50,
}),

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { select, textAreaRequired, textFieldOptional } from "~/form/fields";
import { select, textArea, textFieldOptional } from "~/form/fields";
import { id } from "~/utils/zod";
import { USER_REPORT } from "./user-report-constants";
@@ -17,7 +17,7 @@ export const reportUserSchema = z.object({
{ label: "options.userReportCategory.OTHER", value: "OTHER" },
],
}),
description: textAreaRequired({
description: textArea({
label: "labels.description",
maxLength: USER_REPORT.DESCRIPTION_MAX_LENGTH,
}),

View File

@@ -80,15 +80,11 @@ function renderForm(options?: {
schema={vodFormBaseSchema}
defaultValues={createDefaultValues(options?.defaultValues)}
>
{({ names }) => (
<>
{Object.keys(names)
.filter((name) => name !== "pov")
.map((name) => (
<FormField key={name} name={name} />
))}
</>
)}
{Object.keys(vodFormBaseSchema.shape)
.filter((name) => name !== "pov")
.map((name) => (
<FormField key={name} name={name} />
))}
</SendouForm>
),
},

View File

@@ -10,7 +10,6 @@ import { Main } from "~/components/Main";
import { WeaponSelect } from "~/components/WeaponSelect";
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import type { ArrayItemRenderContext, CustomFieldRenderProps } from "~/form";
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField";
import type { FormRenderProps } from "~/form/SendouForm";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
@@ -240,46 +239,21 @@ function VodFormFields({
}
function TeamSizeField({ FormField }: { FormField: VodFormFieldComponent }) {
const { values, setValue } = useFormFieldContext();
const matches = values.matches as Array<Record<string, unknown>>;
const { setValueFromPrev } = useFormFieldContext();
const handleTeamSizeChange = (newValue: string | null) => {
setValue("teamSize", newValue);
if (matches && Array.isArray(matches)) {
const clearedMatches = matches.map((match) => ({
// The weapon count per match is tied to the team size, so any already picked
// weapons would no longer fit the new size.
const clearMatchWeapons = () => {
setValueFromPrev("matches", (prev) =>
((prev ?? []) as Array<Record<string, unknown>>).map((match) => ({
...match,
weaponsTeamOne: [],
weaponsTeamTwo: [],
}));
setValue("matches", clearedMatches);
}
})),
);
};
return (
<FormField name="teamSize">
{({ name, error, value }: CustomFieldRenderProps) => (
<FormFieldWrapper
id={name}
name={name}
label="forms:labels.vodTeamSize"
error={error}
>
<select
id={name}
name={name}
value={(value as string) ?? "4"}
onChange={(e) => handleTeamSizeChange(e.target.value)}
>
<option value="1">1v1</option>
<option value="2">2v2</option>
<option value="3">3v3</option>
<option value="4">4v4</option>
</select>
</FormFieldWrapper>
)}
</FormField>
);
return <FormField name="teamSize" onValueChange={clearMatchWeapons} />;
}
function PovFormField({ FormField }: { FormField: VodFormFieldComponent }) {
@@ -420,33 +394,19 @@ function MatchFieldsetContent({
</div>
<div className="stack md mt-4">
<FormField name={`${itemName}.startsAt`}>
{(props: CustomFieldRenderProps) => (
<FormFieldWrapper
id={`matches-${index}-startsAt`}
name={`${itemName}.startsAt`}
label="forms:labels.vodStartTimestamp"
error={props.error}
<div>
<FormField name={`${itemName}.startsAt`} />
{currentTime ? (
<SendouButton
variant="minimal"
size="miniscule"
onPress={() => setItemField("startsAt", currentTime)}
className="mt-2"
>
<input
id={`matches-${index}-startsAt`}
value={matchValues.startsAt}
onChange={(e) => setItemField("startsAt", e.target.value)}
placeholder="10:22"
/>
{currentTime ? (
<SendouButton
variant="minimal"
size="miniscule"
onPress={() => setItemField("startsAt", currentTime)}
className="mt-2"
>
{t("vods:forms.action.setAsCurrent", { time: currentTime })}
</SendouButton>
) : null}
</FormFieldWrapper>
)}
</FormField>
{t("vods:forms.action.setAsCurrent", { time: currentTime })}
</SendouButton>
) : null}
</div>
<FormField name={`${itemName}.mode`} />

View File

@@ -3,14 +3,14 @@ import { z } from "zod";
import {
array,
customField,
dayMonthYearRequired,
dayMonthYear as dayMonthYearField,
fieldset,
idConstantOptional,
radioGroup,
select,
selectOptional,
stageSelect,
textFieldRequired,
textField,
weaponPool,
weaponSelectOptional,
} from "~/form/fields";
@@ -116,8 +116,9 @@ const povSchema = z.union([
]);
const matchFieldsetSchema = z.object({
startsAt: textFieldRequired({
startsAt: textField({
label: "labels.vodStartTimestamp",
placeholder: "placeholders.vodStartTimestamp",
maxLength: 10,
regExp: {
pattern: HOURS_MINUTES_SECONDS_REGEX,
@@ -151,7 +152,7 @@ const matchFieldsetSchema = z.object({
export const vodFormBaseSchema = z.object({
vodToEditId: idConstantOptional(),
youtubeUrl: textFieldRequired({
youtubeUrl: textField({
label: "labels.vodYoutubeUrl",
maxLength: 200,
validate: {
@@ -159,11 +160,11 @@ export const vodFormBaseSchema = z.object({
message: "Invalid YouTube URL",
},
}),
title: textFieldRequired({
title: textField({
label: "labels.vodTitle",
maxLength: 100,
}),
date: dayMonthYearRequired({
date: dayMonthYearField({
label: "labels.vodDate",
max: () => add(new Date(), { days: 1 }),
maxMessage: "errors.dateMustNotBeFuture",

View File

@@ -55,6 +55,8 @@ interface FormFieldProps {
name: string;
label?: string;
disabled?: boolean;
/** Focuses the field on mount. Only `text-field` and `text-area` support it. */
autoFocus?: boolean;
maxCount?: number;
field?: z.ZodType;
children?:
@@ -64,17 +66,27 @@ interface FormFieldProps {
options?: unknown;
/** For `array` fields: hide the remove button for items where this returns false. */
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
/**
* Runs after the new value has been stored. For side effects on other fields;
* to change what gets stored use the field schema's own options instead.
*/
onValueChange?: (newValue: unknown) => void;
}
/** Field types that render `children`. Any other type would silently discard it. */
const FIELD_TYPES_WITH_RENDER_PROP = ["custom", "array"];
export function FormField({
name,
label,
disabled,
autoFocus,
maxCount,
field,
children,
options,
canRemoveItem,
onValueChange,
}: FormFieldProps) {
const context = useOptionalFormFieldContext();
const isDisabled = disabled ?? context?.readOnly ?? false;
@@ -153,11 +165,24 @@ export function FormField({
context.setClientError(name, validationError);
};
// After the first submit, changes revalidate the whole form — except array
// appends, which stay silent so a freshly added empty item doesn't error
// immediately. Blur is the moment the user leaves such an item, so
// revalidating here surfaces its error without waiting for the next submit.
const handleBlur = (latestValue?: unknown) => {
if (hasSubmitted) return;
if (!context) return;
if (hasSubmitted) {
context.revalidateAll(context.store.values);
return;
}
runValidation(latestValue ?? value);
};
// Read through a ref so an inline `onValueChange` does not destabilize
// `handleChange`, which fields rely on to skip re-rendering.
const latestOnValueChange = React.useRef(onValueChange);
latestOnValueChange.current = onValueChange;
const handleChange = React.useCallback(
(newValue: unknown) => {
if (!context) return;
@@ -171,12 +196,22 @@ export function FormField({
context.revalidateAll(context.store.values);
}
context.onFieldChange?.(name, newValue);
latestOnValueChange.current?.(newValue);
},
[context, name],
);
const displayedError = serverError ?? clientError;
if (
typeof children === "function" &&
!FIELD_TYPES_WITH_RENDER_PROP.includes(formField.type)
) {
throw new Error(
`Field "${name}" is of type "${formField.type}" which renders itself, so its render function child would never run. Remove the child or change the field to customField().`,
);
}
const commonProps = { name, error: displayedError, onBlur: handleBlur };
if (formField.type === "text-field") {
@@ -185,6 +220,7 @@ export function FormField({
{...commonProps}
{...formField}
disabled={isDisabled}
autoFocus={autoFocus}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
@@ -221,6 +257,7 @@ export function FormField({
{...commonProps}
{...formField}
disabled={isDisabled}
autoFocus={autoFocus}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
@@ -232,6 +269,7 @@ export function FormField({
<SelectFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as string | null}
onChange={handleChange as (v: string | null) => void}
/>
@@ -247,6 +285,7 @@ export function FormField({
<SelectFormField
{...commonProps}
{...formField}
disabled={isDisabled}
items={selectOptions.map((opt) => ({
value: opt.value,
label: opt.label,
@@ -262,6 +301,7 @@ export function FormField({
<DualSelectFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as [string | null, string | null]}
onChange={handleChange as (v: [string | null, string | null]) => void}
/>
@@ -273,6 +313,7 @@ export function FormField({
<RadioGroupFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
@@ -288,6 +329,7 @@ export function FormField({
<RadioGroupFormField
{...commonProps}
{...formField}
disabled={isDisabled}
items={radioItems}
value={value as string}
onChange={handleChange as (v: string) => void}
@@ -300,6 +342,7 @@ export function FormField({
<CheckboxGroupFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as string[]}
onChange={handleChange as (v: string[]) => void}
/>
@@ -311,6 +354,7 @@ export function FormField({
<DatetimeFormField
{...commonProps}
{...formField}
disabled={isDisabled}
granularity={formField.type === "date" ? "day" : "minute"}
value={value as Date | undefined}
onChange={handleChange as (v: Date | undefined) => void}
@@ -323,6 +367,7 @@ export function FormField({
<TimeRangeFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as { start: string; end: string } | null}
onChange={
handleChange as (v: { start: string; end: string } | null) => void
@@ -336,6 +381,7 @@ export function FormField({
<WeaponPoolFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as WeaponPoolItem[]}
onChange={handleChange as (v: WeaponPoolItem[]) => void}
/>
@@ -365,15 +411,13 @@ export function FormField({
error: displayedError,
value,
onChange: handleChange,
disabled: isDisabled,
})}
</>
);
}
if (
formField.type === "string-constant" ||
formField.type === "id-constant"
) {
if (formField.type === "hidden") {
return null;
}
@@ -393,6 +437,7 @@ export function FormField({
<ArrayFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as unknown[]}
onChange={handleChange as (v: unknown[]) => void}
isObjectArray={isObjectArray}
@@ -433,7 +478,12 @@ export function FormField({
}
return (
<FormField key={idx} name={itemName} field={formField.field} />
<FormField
key={idx}
name={itemName}
field={formField.field}
disabled={disabled}
/>
);
}}
/>
@@ -441,7 +491,9 @@ export function FormField({
}
if (formField.type === "fieldset") {
return <FieldsetFormField {...commonProps} {...formField} />;
return (
<FieldsetFormField {...commonProps} {...formField} disabled={disabled} />
);
}
if (formField.type === "user-search") {
@@ -450,6 +502,7 @@ export function FormField({
<UserSearchFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as number | null}
onChange={handleChange as (v: number | null) => void}
onUserSelected={userOptions?.onUserSelected}
@@ -465,6 +518,7 @@ export function FormField({
<TournamentSearchFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as number | null}
onChange={handleChange as (v: number | null) => void}
pastOnly={tournamentOptions?.pastOnly}
@@ -478,6 +532,7 @@ export function FormField({
<TeamSearchFormField
{...commonProps}
{...formField}
disabled={isDisabled}
onChange={handleChange as (v: number | null) => void}
onTeamSelected={teamOptions?.onTeamSelected}
initialTeam={teamOptions?.initialTeam}
@@ -493,6 +548,7 @@ export function FormField({
<BadgesFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as number[]}
onChange={handleChange as (v: number[]) => void}
options={options as BadgeOption[]}
@@ -506,6 +562,7 @@ export function FormField({
<StageSelectFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as StageId | null}
onChange={handleChange as (v: StageId) => void}
/>
@@ -517,6 +574,7 @@ export function FormField({
<WeaponSelectFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as MainWeaponId | null}
onChange={handleChange as (v: MainWeaponId | null) => void}
/>

View File

@@ -13,10 +13,10 @@ import {
radioGroup,
select,
selectOptional,
textArea,
textAreaOptional,
textAreaRequired,
textField,
textFieldOptional,
textFieldRequired,
timeRangeOptional,
toggle as toggleField,
userSearch,
@@ -46,7 +46,7 @@ function renderForm(
defaultValues?: Record<string, unknown>;
title?: string;
submitButtonText?: string;
autoSubmit?: boolean;
mode?: "autoSubmit";
},
) {
const props: ComponentProps<typeof SendouForm<z.ZodRawShape>> = {
@@ -54,10 +54,10 @@ function renderForm(
defaultValues: options?.defaultValues,
title: options?.title,
submitButtonText: options?.submitButtonText,
autoSubmit: options?.autoSubmit,
children: ({ names }) => (
mode: options?.mode,
children: (
<>
{Object.keys(names).map((name) => (
{Object.keys(schema.shape).map((name) => (
<FormField key={name} name={name} />
))}
</>
@@ -85,7 +85,7 @@ describe("SendouForm", () => {
describe("basic form rendering", () => {
test("renders form with title", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, { title: "Test Form" });
@@ -95,7 +95,7 @@ describe("SendouForm", () => {
test("renders submit button with default text", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -107,7 +107,7 @@ describe("SendouForm", () => {
test("renders submit button with custom text", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, {
@@ -119,12 +119,12 @@ describe("SendouForm", () => {
.toBeVisible();
});
test("hides submit button when autoSubmit is true", async () => {
test("hides submit button in autoSubmit mode", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, { autoSubmit: true });
const screen = await renderForm(schema, { mode: "autoSubmit" });
const submitButton = screen.container.querySelector(
'button[type="submit"]',
@@ -136,7 +136,7 @@ describe("SendouForm", () => {
describe("text field", () => {
test("renders with label", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -146,7 +146,7 @@ describe("SendouForm", () => {
test("typing updates value", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -159,7 +159,7 @@ describe("SendouForm", () => {
test("shows error on blur when required field is empty", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -175,7 +175,7 @@ describe("SendouForm", () => {
test("shows error on submit when required field is empty", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -189,7 +189,7 @@ describe("SendouForm", () => {
test("clears error when valid value is entered after submit", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -221,7 +221,7 @@ describe("SendouForm", () => {
test("initializes with default value", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, {
@@ -312,7 +312,7 @@ describe("SendouForm", () => {
test("required text area shows error when empty", async () => {
const schema = z.object({
bio: textAreaRequired({ label: "labels.bio", maxLength: 500 }),
bio: textArea({ label: "labels.bio", maxLength: 500 }),
});
const screen = await renderForm(schema);
@@ -599,8 +599,8 @@ describe("SendouForm", () => {
describe("validation", () => {
test("validates multiple fields on submit", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
bio: textAreaRequired({ label: "labels.bio", maxLength: 500 }),
name: textField({ label: "labels.name", maxLength: 100 }),
bio: textArea({ label: "labels.bio", maxLength: 500 }),
});
const screen = await renderForm(schema);
@@ -618,7 +618,7 @@ describe("SendouForm", () => {
describe("default values", () => {
test("initializes multiple fields with default values", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }),
});
@@ -659,7 +659,7 @@ describe("SendouForm", () => {
describe("server error fallback", () => {
test("shows fallback error when server returns error for field without DOM element", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
mockFetcherData = {
@@ -677,7 +677,7 @@ describe("SendouForm", () => {
test("does not show fallback error when server error has corresponding DOM element", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
mockFetcherData = {
@@ -741,7 +741,7 @@ describe("SendouForm", () => {
test("calls onApply with form values instead of fetcher.submit", async () => {
const onApply = vi.fn();
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const router = createMemoryRouter(
@@ -754,7 +754,7 @@ describe("SendouForm", () => {
defaultValues={{ name: "Test Value" }}
onApply={onApply}
>
{({ names }) => <FormField name={names.name} />}
<FormField name="name" />
</SendouForm>
),
},
@@ -771,7 +771,7 @@ describe("SendouForm", () => {
test("does not call onApply when validation fails", async () => {
const onApply = vi.fn();
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
});
const router = createMemoryRouter(
@@ -780,7 +780,7 @@ describe("SendouForm", () => {
path: "/",
element: (
<SendouForm schema={schema} onApply={onApply}>
{({ names }) => <FormField name={names.name} />}
<FormField name="name" />
</SendouForm>
),
},
@@ -804,7 +804,7 @@ describe("SendouForm", () => {
member: fieldset({
label: "labels.member",
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
});
@@ -819,7 +819,7 @@ describe("SendouForm", () => {
member: fieldset({
label: "labels.member",
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }),
}),
}),
@@ -836,7 +836,7 @@ describe("SendouForm", () => {
member: fieldset({
label: "labels.member",
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
});
@@ -854,7 +854,7 @@ describe("SendouForm", () => {
member: fieldset({
label: "labels.member",
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
});
@@ -876,7 +876,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 5,
field: textFieldRequired({ maxLength: 100 }),
field: textField({ maxLength: 100 }),
}),
});
@@ -893,7 +893,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 5,
field: textFieldRequired({ maxLength: 100 }),
field: textField({ maxLength: 100 }),
}),
});
@@ -914,7 +914,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 5,
field: textFieldRequired({ maxLength: 100 }),
field: textField({ maxLength: 100 }),
}),
});
@@ -939,7 +939,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 5,
field: textFieldRequired({ maxLength: 100 }),
field: textField({ maxLength: 100 }),
}),
});
@@ -959,7 +959,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 5,
field: textFieldRequired({ maxLength: 100 }),
field: textField({ maxLength: 100 }),
}),
});
@@ -985,7 +985,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 2,
field: textFieldRequired({ maxLength: 100 }),
field: textField({ maxLength: 100 }),
}),
});
@@ -1007,7 +1007,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1029,7 +1029,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1056,7 +1056,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1078,7 +1078,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1109,7 +1109,7 @@ describe("SendouForm", () => {
sortable: true,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1147,7 +1147,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1173,7 +1173,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
role: select({
label: "labels.staffRole",
items: [
@@ -1222,7 +1222,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1251,7 +1251,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
role: select({
label: "labels.staffRole",
items: [
@@ -1270,7 +1270,7 @@ describe("SendouForm", () => {
path: "/",
element: (
<SendouForm schema={schema} onApply={onApply}>
{({ names }) => <FormField name={names.staff} />}
<FormField name="staff" />
</SendouForm>
),
},
@@ -1296,7 +1296,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
description: textAreaOptional({
label: "labels.description",
maxLength: 500,
@@ -1324,7 +1324,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1416,12 +1416,8 @@ describe("SendouForm", () => {
path: "/",
element: (
<SendouForm schema={schema} defaultValues={defaultValues}>
{({ names }) => (
<>
<FormField name={names.members} />
<ValueCapture />
</>
)}
<FormField name="members" />
<ValueCapture />
</SendouForm>
),
},
@@ -1459,7 +1455,7 @@ describe("SendouForm", () => {
describe("render isolation", () => {
test("typing in one field does not re-render sibling fields", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
name: textField({ label: "labels.name", maxLength: 100 }),
bio: textFieldOptional({ label: "labels.bio", maxLength: 100 }),
});

View File

@@ -6,12 +6,14 @@ import type { FetcherWithComponents } from "react-router";
import { useFetcher, useLocation } from "react-router";
import { isPlainObject } from "remeda";
import type { z } from "zod";
import type { SendouButtonProps } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { SubmitButton } from "~/components/SubmitButton";
import { FormField as FormFieldComponent } from "./FormField";
import { getFormFieldMetadata } from "./fields";
import styles from "./SendouForm.module.css";
import type { TypedFormFieldComponent } from "./types";
import { useUnsavedChangesChecker } from "./UnsavedChangesGuard";
import {
buildFieldPath,
errorMessageId,
@@ -37,7 +39,6 @@ export interface FormContextValue<T extends z.ZodRawShape = z.ZodRawShape> {
setClientError: (name: string, error: string | undefined) => void;
clearServerError: (name: string) => void;
onFieldChange?: (name: string, newValue: unknown) => void;
hideRequiredIndicator: boolean;
readOnly: boolean;
values: Record<string, unknown>;
setValue: (name: string, value: unknown) => void;
@@ -56,9 +57,12 @@ export interface FormContextValue<T extends z.ZodRawShape = z.ZodRawShape> {
interface FormStore {
values: Record<string, unknown>;
clientErrors: Partial<Record<string, string>>;
/** Has the user edited any field since mount / the last successful submit? */
dirty: boolean;
subscribe: (listener: () => void) => () => void;
setValues: (values: Record<string, unknown>) => void;
setClientErrors: (errors: Partial<Record<string, string>>) => void;
setDirty: (dirty: boolean) => void;
}
type FormFieldContextValue = Omit<
@@ -72,27 +76,27 @@ const FormContext = React.createContext<FormFieldContextValue | null>(null);
export const EMPTY_FORM_STORE = createFormStore({}, {});
type FormNames<T extends z.ZodRawShape> = {
[K in keyof T]: K;
};
export interface FormRenderProps<T extends z.ZodRawShape> {
names: FormNames<T>;
FormField: TypedFormFieldComponent<T>;
}
export type FormMode = "submit" | "autoSubmit" | "client";
type BaseFormProps<T extends z.ZodRawShape> = {
children: React.ReactNode | ((props: FormRenderProps<T>) => React.ReactNode);
schema: z.ZodObject<T>;
title?: React.ReactNode;
submitButtonText?: React.ReactNode;
action?: string;
method?: "post" | "get";
_action?: string;
submitButtonTestId?: string;
autoSubmit?: boolean;
autoApply?: boolean;
/** Styling of the submit button, for forms embedded somewhere the default button is too heavy. */
submitButtonVariant?: SendouButtonProps["variant"];
submitButtonSize?: SendouButtonProps["size"];
revalidateRoot?: boolean;
/**
* Replaces the default form layout classes entirely (it does not merge with
* them), so `fullWidth` has no effect when this is set.
*/
className?: string;
/**
* When true, opts out of the default centered, max-width layout so the form
@@ -100,18 +104,11 @@ type BaseFormProps<T extends z.ZodRawShape> = {
* layout that already controls width/alignment.
*/
fullWidth?: boolean;
/**
* When true, fields don't show the red `*` required indicator next to their
* label. Useful on pages where every field is required and the asterisk only
* adds noise (e.g. the settings page).
*/
hideRequiredIndicator?: boolean;
/**
* When true, renders the form for viewing only: every field is disabled and
* the submit button is hidden.
*/
readOnly?: boolean;
onApply?: (values: z.infer<z.ZodObject<T>>) => void;
secondarySubmit?: React.ReactNode;
/**
* Called once after a server submission completes successfully (the action
@@ -121,22 +118,43 @@ type BaseFormProps<T extends z.ZodRawShape> = {
onSuccess?: () => void;
};
/**
* How submitting works:
* - `"submit"` (default): the user submits via the submit button. Values go to
* the server, or to `onApply` when provided.
* - `"autoSubmit"`: no submit button; every change that passes validation is
* sent to the server.
* - `"client"`: no submit button and no `<form>` element; every change is
* passed to `onApply` and field errors are computed already on mount.
*/
type FormModeProps<T extends z.ZodRawShape> =
| {
mode?: "submit";
/** When set, a valid submit is handed to this callback instead of being sent to the server. */
onApply?: (values: z.infer<z.ZodObject<T>>) => void;
}
| { mode: "autoSubmit"; onApply?: never }
| { mode: "client"; onApply: (values: z.infer<z.ZodObject<T>>) => void };
export type FormDefaultValues<T extends z.ZodRawShape> = Partial<
z.input<z.ZodObject<T>>
>;
type SendouFormProps<T extends z.ZodRawShape> = BaseFormProps<T> &
FormModeProps<T> &
(HasRequiredDefaults<T> extends true
? {
defaultValues: Partial<z.input<z.ZodObject<T>>> &
defaultValues: FormDefaultValues<T> &
Record<RequiredDefaultKeys<T>, unknown>;
}
: { defaultValues?: Partial<z.input<z.ZodObject<T>>> | null });
: { defaultValues?: FormDefaultValues<T> | null });
interface LatestFormProps {
schema: z.ZodObject<z.ZodRawShape>;
onApply: ((values: Record<string, unknown>) => void) | undefined;
method: "post" | "get";
action: string | undefined;
revalidateRoot: boolean | undefined;
autoSubmit: boolean | undefined;
autoApply: boolean | undefined;
mode: FormMode;
fetcher: FetcherWithComponents<{ fieldErrors?: Record<string, string> }>;
t: (key: string) => string;
}
@@ -148,16 +166,14 @@ export function SendouForm<T extends z.ZodRawShape>({
title,
submitButtonText,
action,
method = "post",
_action,
submitButtonTestId,
autoSubmit,
autoApply,
submitButtonVariant,
submitButtonSize,
revalidateRoot,
className,
fullWidth,
hideRequiredIndicator = false,
readOnly = false,
mode = "submit",
onApply,
secondarySubmit,
onSuccess,
@@ -175,7 +191,9 @@ export function SendouForm<T extends z.ZodRawShape>({
const initialValues = buildInitialValues(schema, defaultValues);
storeRef.current = createFormStore(
initialValues,
autoApply ? computeTopLevelFieldErrors(schema, initialValues) : {},
mode === "client"
? computeTopLevelFieldErrors(schema, initialValues)
: {},
);
}
const store = storeRef.current;
@@ -183,11 +201,9 @@ export function SendouForm<T extends z.ZodRawShape>({
const latestProps: LatestFormProps = {
schema: schema as z.ZodObject<z.ZodRawShape>,
onApply: onApply as unknown as LatestFormProps["onApply"],
method,
action,
revalidateRoot,
autoSubmit,
autoApply,
mode,
fetcher,
t: t as unknown as LatestFormProps["t"],
};
@@ -215,6 +231,7 @@ export function SendouForm<T extends z.ZodRawShape>({
store.setValues(buildInitialValues(schema, defaultValues));
store.setClientErrors({});
store.setDirty(false);
setHasSubmitted(false);
setFallbackError(null);
}, [locationKey]);
@@ -243,13 +260,17 @@ export function SendouForm<T extends z.ZodRawShape>({
setFallbackError(null);
const firstErrorField = errorEntries[0][0];
const firstErrorElement = document.getElementById(
errorMessageId(firstErrorField),
const firstError = findFirstErrorElementInDomOrder(
errorEntries.map(([fieldName]) => fieldName),
);
firstErrorElement?.scrollIntoView({ behavior: "smooth", block: "center" });
if (firstError) focusAndScrollToError(firstError);
}, [fetcher.data, t]);
const hasUnsavedChangesRef = React.useRef<() => boolean>(() => false);
hasUnsavedChangesRef.current = () =>
mode === "submit" && !readOnly && store.dirty && fetcher.state === "idle";
useUnsavedChangesChecker(hasUnsavedChangesRef);
const previousFetcherStateRef = React.useRef(fetcher.state);
React.useEffect(() => {
if (
@@ -257,10 +278,11 @@ export function SendouForm<T extends z.ZodRawShape>({
fetcher.state === "idle" &&
!fetcher.data?.fieldErrors
) {
store.setDirty(false);
onSuccess?.();
}
previousFetcherStateRef.current = fetcher.state;
}, [fetcher.state, fetcher.data, onSuccess]);
}, [fetcher.state, fetcher.data, onSuccess, store]);
const contextValue = React.useMemo<FormFieldContextValue>(
() => ({
@@ -270,9 +292,7 @@ export function SendouForm<T extends z.ZodRawShape>({
hasSubmitted,
setClientError: actions.setClientError,
clearServerError: actions.clearServerError,
onFieldChange:
autoSubmit || autoApply ? actions.onFieldChange : undefined,
hideRequiredIndicator,
onFieldChange: mode !== "submit" ? actions.onFieldChange : undefined,
readOnly,
setValue: actions.setValue,
setValueFromPrev: actions.setValueFromPrev,
@@ -286,9 +306,7 @@ export function SendouForm<T extends z.ZodRawShape>({
defaultValues,
visibleServerErrors,
hasSubmitted,
autoSubmit,
autoApply,
hideRequiredIndicator,
mode,
readOnly,
fetcher.state,
store,
@@ -296,14 +314,9 @@ export function SendouForm<T extends z.ZodRawShape>({
],
);
const names = Object.fromEntries(
Object.keys(schema.shape).map((key) => [key, key]),
) as FormNames<T>;
const resolvedChildren =
typeof children === "function"
? children({
names,
FormField: FormFieldComponent as TypedFormFieldComponent<T>,
})
: children;
@@ -312,12 +325,13 @@ export function SendouForm<T extends z.ZodRawShape>({
<>
{title ? <h2 className={styles.title}>{title}</h2> : null}
<React.Fragment key={locationKey}>{resolvedChildren}</React.Fragment>
{autoSubmit || autoApply || readOnly ? null : (
{mode !== "submit" || readOnly ? null : (
<div className="mt-4 stack horizontal md mx-auto justify-center items-center">
<SubmitButton
_action={_action}
testId={submitButtonTestId}
state={fetcher.state}
variant={submitButtonVariant}
size={submitButtonSize}
>
{submitButtonText ?? t("submit")}
</SubmitButton>
@@ -337,11 +351,11 @@ export function SendouForm<T extends z.ZodRawShape>({
return (
<FormContext.Provider value={contextValue}>
{autoApply && onApply ? (
{mode === "client" ? (
<div className={resolvedClassName}>{formContent}</div>
) : (
<form
method={method}
method="post"
action={action}
className={resolvedClassName}
noValidate
@@ -368,6 +382,7 @@ function createFormStore(
const store: FormStore = {
values: initialValues,
clientErrors: initialClientErrors,
dirty: false,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
@@ -380,6 +395,10 @@ function createFormStore(
store.clientErrors = errors;
notify();
},
// Read only at navigation time (unsaved-changes guard), so no notify.
setDirty(dirty) {
store.dirty = dirty;
},
};
return store;
@@ -410,20 +429,19 @@ function createFormActions({
setFallbackError,
}: FormActionDeps) {
const scrollToFirstError = (errors: Record<string, string>) => {
const firstErrorField = Object.keys(errors)[0];
if (!firstErrorField) return;
const errorFieldNames = Object.keys(errors);
if (errorFieldNames.length === 0) return;
const errorElement = document.getElementById(
errorMessageId(firstErrorField),
);
if (errorElement) {
errorElement.scrollIntoView({ behavior: "smooth", block: "center" });
const firstError = findFirstErrorElementInDomOrder(errorFieldNames);
if (firstError) {
focusAndScrollToError(firstError);
setFallbackError(null);
} else {
const firstError = errors[firstErrorField];
const firstErrorField = errorFieldNames[0];
const firstErrorMessage = errors[firstErrorField];
setFallbackError(
firstError
? `${latest.current.t(firstError)} (${firstErrorField})`
firstErrorMessage
? `${latest.current.t(firstErrorMessage)} (${firstErrorField})`
: null,
);
}
@@ -447,12 +465,12 @@ function createFormActions({
};
const submitValues = (values: Record<string, unknown>) => {
const { fetcher, method, action, revalidateRoot } = latest.current;
const { fetcher, action, revalidateRoot } = latest.current;
const submitted = revalidateRoot
? { ...values, revalidateRoot: true }
: values;
fetcher.submit(submitted as Record<string, string>, {
method,
method: "post",
action,
encType: "application/json",
});
@@ -491,6 +509,7 @@ function createFormActions({
};
const setValue = (name: string, newValue: unknown) => {
store.setDirty(true);
if (name.includes(".") || name.includes("[")) {
store.setValues(
setNestedValue(
@@ -508,6 +527,7 @@ function createFormActions({
name: string,
updater: (prev: unknown) => unknown,
) => {
store.setDirty(true);
store.setValues({ ...store.values, [name]: updater(store.values[name]) });
};
@@ -520,13 +540,17 @@ function createFormActions({
const submitToServer = (valuesToSubmit: Record<string, unknown>) => {
if (!validateAndPrepare()) return;
// Cleared before `onApply` because it may navigate synchronously (e.g.
// calendar filters set search params) — the blocker would otherwise still
// see the form as dirty and block that navigation.
store.setDirty(false);
latest.current.onApply?.(store.values);
submitValues(valuesToSubmit);
};
const onFieldChange = (changedName: string, changedValue: unknown) => {
const { schema, autoSubmit, autoApply, onApply } = latest.current;
const { schema, mode, onApply } = latest.current;
const isNestedPath = changedName.includes(".") || changedName.includes("[");
const updatedValues = isNestedPath
? setNestedValue(store.values, changedName, changedValue)
@@ -536,9 +560,9 @@ function createFormActions({
store.setClientErrors(newErrors);
const hasFieldErrors = Object.keys(newErrors).length > 0;
if (autoApply && onApply) {
onApply(updatedValues);
} else if (autoSubmit && !hasFieldErrors) {
if (mode === "client") {
onApply?.(updatedValues);
} else if (mode === "autoSubmit" && !hasFieldErrors) {
submitValues(updatedValues);
}
};
@@ -549,6 +573,10 @@ function createFormActions({
const { onApply } = latest.current;
if (onApply) {
// Cleared before `onApply` because it may navigate synchronously (e.g.
// calendar filters set search params) — the blocker would otherwise
// still see the form as dirty and block that navigation.
store.setDirty(false);
onApply(store.values);
} else {
submitValues(store.values);
@@ -681,7 +709,6 @@ export function useFormFieldContext(): FormContextValue {
setClientError: context.setClientError,
clearServerError: context.clearServerError,
onFieldChange: context.onFieldChange,
hideRequiredIndicator: context.hideRequiredIndicator,
readOnly: context.readOnly,
values,
setValue: context.setValue,
@@ -695,3 +722,54 @@ export function useFormFieldContext(): FormContextValue {
export function useOptionalFormFieldContext() {
return React.useContext(FormContext);
}
/**
* "First" error means first in DOM order, not first in error-map insertion
* order — validation collects errors in schema order which does not have to
* match the rendered field order.
*/
function findFirstErrorElementInDomOrder(errorFieldNames: string[]) {
const errorElements = errorFieldNames.flatMap((name) => {
const element = document.getElementById(errorMessageId(name));
return element ? [{ name, element }] : [];
});
errorElements.sort((a, b) =>
a.element.compareDocumentPosition(b.element) &
Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1,
);
return errorElements.at(0);
}
/**
* Moves focus to the failing field so keyboard and screen reader users are
* taken to the problem, not just scrolled past it. Prefers the control that
* references the error via `aria-errormessage`, then any focusable control in
* the same wrapper, and as a last resort the error message element itself.
*/
function focusAndScrollToError({
name,
element,
}: {
name: string;
element: HTMLElement;
}) {
const control = document.querySelector<HTMLElement>(
`[aria-errormessage="${errorMessageId(name)}"]`,
);
const focusTarget =
control ??
element.parentElement?.querySelector<HTMLElement>(
"input, select, textarea, button",
) ??
element;
if (focusTarget === element) {
element.setAttribute("tabindex", "-1");
}
focusTarget.focus({ preventScroll: true });
element.scrollIntoView({ behavior: "smooth", block: "center" });
}

View File

@@ -0,0 +1,84 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useBlocker } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
const dirtyCheckers = new Set<() => boolean>();
/**
* Confirms navigating away when any mounted form has unsaved changes.
* Rendered once in the root layout because react-router supports only a
* single active blocker at a time — individual forms register a checker via
* `useUnsavedChangesChecker` instead of calling `useBlocker` themselves.
*/
export function UnsavedChangesGuard() {
const { t } = useTranslation(["common", "forms"]);
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
(currentLocation.pathname !== nextLocation.pathname ||
currentLocation.search !== nextLocation.search) &&
hasUnsavedChanges(),
);
React.useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasUnsavedChanges()) return;
event.preventDefault();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
if (blocker.state !== "blocked") return null;
return (
<SendouDialog
heading={t("forms:unsavedChanges.title")}
onClose={() => blocker.reset()}
isDismissable
>
<div className="stack md stack md text-sm text-lighter">
{t("forms:unsavedChanges.body")}
<div className="stack horizontal md justify-center">
<SendouButton variant="outlined" onPress={() => blocker.reset()}>
{t("common:actions.cancel")}
</SendouButton>
<SendouButton
variant="destructive"
onPress={() => blocker.proceed()}
data-testid="discard-changes-button"
>
{t("forms:unsavedChanges.discard")}
</SendouButton>
</div>
</div>
</SendouDialog>
);
}
/**
* Registers a callback reporting whether the calling form currently has
* unsaved changes. The ref indirection lets the callback read the latest
* form state without re-registering on every render.
*/
export function useUnsavedChangesChecker(
checkerRef: React.RefObject<() => boolean>,
) {
React.useEffect(() => {
const checker = () => checkerRef.current();
dirtyCheckers.add(checker);
return () => {
dirtyCheckers.delete(checker);
};
}, [checkerRef]);
}
function hasUnsavedChanges() {
for (const checker of dirtyCheckers) {
if (checker()) return true;
}
return false;
}

View File

@@ -61,8 +61,17 @@ type WithTypedTranslationKeys<T> = Omit<
placeholder?: FormsTranslationKey;
};
type TypedItemLabel<V extends string> = {
label: FormsTranslationKey | (() => string);
value: V;
};
type WithTypedItemLabels<T, V extends string> = Omit<T, "items"> & {
items: Array<{ label: FormsTranslationKey | (() => string); value: V }>;
items: Array<TypedItemLabel<V>>;
};
type WithTypedItemLabelsWithImage<T, V extends string> = Omit<T, "items"> & {
items: Array<TypedItemLabel<V> & { imgSrc?: string }>;
};
type WithTypedDualSelectFields<T, V extends string> = Omit<
@@ -89,8 +98,8 @@ function prefixKey(key: FormsTranslationKey | undefined): string | undefined {
return key ? `forms:${key}` : undefined;
}
function prefixItems<V extends string>(
items: Array<{ label: FormsTranslationKey | (() => string); value: V }>,
function prefixItems<V extends string, T extends TypedItemLabel<V>>(
items: Array<T>,
) {
return items.map((item) => ({
...item,
@@ -127,52 +136,46 @@ export function customField<T extends z.ZodType>(
});
}
export function textFieldOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
"type" | "initialValue" | "required"
>
>,
) {
type TextFieldArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
"type" | "initialValue" | "required"
>
>;
export function textFieldOptional(args: TextFieldArgs) {
const schema =
args.validate === "url"
? z.url()
: safeNullableStringSchema({ min: args.minLength, max: args.maxLength });
return textFieldRefined(schema, args).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
placeholder: prefixKey(args.placeholder),
required: false,
type: "text-field",
initialValue: "",
});
return registerTextField(schema, args, false);
}
export function textFieldRequired(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
"type" | "initialValue" | "required"
>
>,
) {
export function textField(args: TextFieldArgs) {
const schema =
args.validate === "url"
? z.string().url()
: safeStringSchema({ min: args.minLength, max: args.maxLength });
return textFieldRefined(schema, args).register(formRegistry, {
return registerTextField(schema, args, true);
}
function registerTextField<T extends z.ZodType<string | null>>(
schema: T,
args: TextFieldArgs,
required: boolean,
): T {
const refined = textFieldRefined(schema, args) as z.ZodType<string | null>;
return refined.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
placeholder: prefixKey(args.placeholder),
required: true,
required,
type: "text-field",
initialValue: "",
});
}) as T;
}
function textFieldRefined<T extends z.ZodType<string | null>>(
@@ -238,102 +241,97 @@ export function inGameName(
});
}
type NumberFieldArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
| "type"
| "initialValue"
| "required"
| "validate"
| "inputType"
| "maxLength"
>
> & { maxLength?: number };
export function numberField(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
| "type"
| "initialValue"
| "required"
| "validate"
| "inputType"
| "maxLength"
>
> & { maxLength?: number },
args: NumberFieldArgs & { min?: number; max?: number },
) {
return z.coerce
.number()
.int({ message: "forms:errors.mustBeWholeNumber" })
.nonnegative()
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
required: true,
type: "text-field",
inputType: "number",
initialValue: "",
maxLength: args.maxLength ?? 10,
});
let schema = numberSchema();
// an empty field coerces to 0, so `min` is also what makes a required number
// field reject being left blank
if (typeof args.min === "number") {
schema = schema.min(args.min, { message: "forms:errors.numberOutOfRange" });
}
if (typeof args.max === "number") {
schema = schema.max(args.max, { message: "forms:errors.numberOutOfRange" });
}
return schema.register(formRegistry, numberFieldMetadata(args, true));
}
export function numberFieldOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
| "type"
| "initialValue"
| "required"
| "validate"
| "inputType"
| "maxLength"
>
> & { maxLength?: number },
) {
return z.coerce
.number()
.int({ message: "forms:errors.mustBeWholeNumber" })
.nonnegative()
export function numberFieldOptional(args: NumberFieldArgs) {
return numberSchema()
.optional()
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
required: false,
type: "text-field",
inputType: "number",
initialValue: "",
maxLength: args.maxLength ?? 10,
});
.register(formRegistry, numberFieldMetadata(args, false));
}
export function textAreaOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-area" }>,
"type" | "initialValue" | "required"
>
>,
) {
return safeNullableStringSchema({ max: args.maxLength }).register(
formRegistry,
{
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
required: false,
type: "text-area",
initialValue: "",
},
);
function numberSchema() {
return z.coerce
.number()
.int({ message: "forms:errors.mustBeWholeNumber" })
.nonnegative();
}
export function textAreaRequired(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-area" }>,
"type" | "initialValue" | "required"
>
>,
) {
return safeStringSchema({ max: args.maxLength }).register(formRegistry, {
function numberFieldMetadata(args: NumberFieldArgs, required: boolean) {
return {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
required: true,
required,
type: "text-field" as const,
inputType: "number" as const,
initialValue: "",
maxLength: args.maxLength ?? 10,
};
}
type TextAreaArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-area" }>,
"type" | "initialValue" | "required"
>
>;
export function textAreaOptional(args: TextAreaArgs) {
return registerTextArea(
safeNullableStringSchema({ max: args.maxLength }),
args,
false,
);
}
export function textArea(args: TextAreaArgs) {
return registerTextArea(
safeStringSchema({ max: args.maxLength }),
args,
true,
);
}
function registerTextArea<T extends z.ZodType<string | null>>(
schema: T,
args: TextAreaArgs,
required: boolean,
): T {
return (schema as z.ZodType<string | null>).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
required,
type: "text-area",
initialValue: "",
});
}) as T;
}
export function toggle(
@@ -390,7 +388,10 @@ export function select<V extends string>(
Omit<FormFieldSelect<"select", V>, "type" | "initialValue" | "clearable">,
V
>
>,
> & {
/** Value selected when the form has no default value for the field. Defaults to the first item. */
initialValue?: V;
},
) {
return itemsSchema(args.items).register(formRegistry, {
...args,
@@ -398,7 +399,7 @@ export function select<V extends string>(
bottomText: prefixKey(args.bottomText),
items: prefixItems(args.items),
type: "select",
initialValue: args.items[0].value,
initialValue: args.initialValue ?? args.items[0].value,
clearable: false,
});
}
@@ -488,7 +489,7 @@ export function dualSelectOptional<V extends string>(
export function radioGroup<V extends string>(
args: WithTypedTranslationKeys<
WithTypedItemLabels<
WithTypedItemLabelsWithImage<
Omit<FormFieldInputGroup<"radio-group", V>, "type" | "initialValue">,
V
>
@@ -529,93 +530,73 @@ type DateTimeArgs = WithTypedTranslationKeys<
maxMessage?: FormsTranslationKey;
};
export function datetimeRequired(args: DateTimeArgs) {
function boundedDate(args: DateTimeArgs, schema: z.ZodDate) {
const resolveMin = args.min ?? (() => new Date(Date.UTC(2015, 4, 28)));
const resolveMax = args.max ?? (() => new Date(Date.UTC(2030, 4, 28)));
return schema
.refine((d) => d >= resolveMin(), {
message: `forms:${args.minMessage ?? "errors.dateTooEarly"}`,
})
.refine((d) => d <= resolveMax(), {
message: `forms:${args.maxMessage ?? "errors.dateTooLate"}`,
});
}
function datetimeMetadata(
args: DateTimeArgs,
overrides: { type: "datetime" | "date"; required: boolean },
) {
return {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
initialValue: null,
...overrides,
};
}
export function datetime(args: DateTimeArgs) {
return z
.preprocess(
date,
z
.date({ message: "forms:errors.required" })
.refine((d) => d >= resolveMin(), {
message: `forms:${args.minMessage ?? "errors.dateTooEarly"}`,
})
.refine((d) => d <= resolveMax(), {
message: `forms:${args.maxMessage ?? "errors.dateTooLate"}`,
}),
boundedDate(args, z.date({ message: "forms:errors.required" })),
)
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "datetime",
initialValue: null,
required: true,
});
.register(
formRegistry,
datetimeMetadata(args, { type: "datetime", required: true }),
);
}
export function datetimeOptional(args: DateTimeArgs) {
const resolveMin = args.min ?? (() => new Date(Date.UTC(2015, 4, 28)));
const resolveMax = args.max ?? (() => new Date(Date.UTC(2030, 4, 28)));
return z
.preprocess(
date,
z
.date()
.refine((d) => d >= resolveMin(), {
message: `forms:${args.minMessage ?? "errors.dateTooEarly"}`,
})
.refine((d) => d <= resolveMax(), {
message: `forms:${args.maxMessage ?? "errors.dateTooLate"}`,
})
.nullish(),
)
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "datetime",
initialValue: null,
required: false,
});
.preprocess(date, boundedDate(args, z.date()).nullish())
.register(
formRegistry,
datetimeMetadata(args, { type: "datetime", required: false }),
);
}
export function dayMonthYearRequired(args: DateTimeArgs) {
const resolveMin = args.min ?? (() => new Date(Date.UTC(2015, 4, 28)));
const resolveMax = args.max ?? (() => new Date(Date.UTC(2030, 4, 28)));
export function dayMonthYear(args: DateTimeArgs) {
return z
.preprocess(
date,
z
.date({ message: "forms:errors.required" })
.refine((d) => d >= resolveMin(), {
message: `forms:${args.minMessage ?? "errors.dateTooEarly"}`,
})
.refine((d) => d <= resolveMax(), {
message: `forms:${args.maxMessage ?? "errors.dateTooLate"}`,
}),
boundedDate(args, z.date({ message: "forms:errors.required" })),
)
.transform((d) => ({
day: d.getDate(),
month: d.getMonth(),
year: d.getFullYear(),
}))
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "date",
initialValue: null,
required: true,
});
.register(
formRegistry,
datetimeMetadata(args, { type: "date", required: true }),
);
}
export function checkboxGroup<V extends string>(
args: WithTypedTranslationKeys<
WithTypedItemLabels<
WithTypedItemLabelsWithImage<
Omit<FormFieldInputGroup<"checkbox-group", V>, "type" | "initialValue">,
V
>
@@ -673,33 +654,45 @@ export function weaponPool(
});
}
export function stringConstant<T extends string>(value: T) {
/**
* Field that renders no control at all. Use it for values the form needs to
* submit but the user never edits, e.g. a discriminator seeded from the loader.
*
* Pass `initialValue` to hardcode the starting value. Omitting it makes the
* field require a matching entry in the form's `defaultValues`.
*/
export function hidden<T extends z.ZodType>(
schema: T,
initialValue: z.input<T>,
): T;
export function hidden<T extends z.ZodType>(schema: T): RequiresDefault<T>;
export function hidden<T extends z.ZodType>(
schema: T,
initialValue?: z.input<T>,
) {
// @ts-expect-error Complex generic type with registry
return z.literal(value).register(formRegistry, {
type: "string-constant",
initialValue: value,
value,
});
return schema.register(formRegistry, {
type: "hidden",
initialValue,
}) as never;
}
export function stringConstant<T extends string>(value: T) {
return hidden(z.literal(value), value);
}
export function idConstant<T extends number>(value: T): z.ZodLiteral<T>;
export function idConstant(): RequiresDefault<z.ZodNumber>;
export function idConstant<T extends number>(value?: T) {
const schema = value !== undefined ? z.literal(value) : id.clone();
return schema.register(formRegistry, {
type: "id-constant",
initialValue: value,
value: value ?? null,
}) as never;
return (
value !== undefined ? hidden(z.literal(value), value) : hidden(id.clone())
) as never;
}
export function idConstantOptional<T extends number>(value?: T) {
const schema = value ? z.literal(value).optional() : id.optional();
return schema.register(formRegistry, {
type: "id-constant",
initialValue: value,
value: value ?? null,
});
return value
? hidden(z.literal(value).optional(), value)
: hidden(id.optional(), undefined);
}
export function array<S extends z.ZodType>(
@@ -764,40 +757,30 @@ export function fieldset<S extends z.ZodRawShape>(
});
}
export function userSearch(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "user-search" }>,
"type" | "initialValue" | "required"
>
>,
) {
return id.clone().register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "user-search",
initialValue: null,
required: true,
});
type UserSearchArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "user-search" }>,
"type" | "initialValue" | "required"
>
>;
export function userSearch(args: UserSearchArgs) {
return id.clone().register(formRegistry, userSearchMetadata(args, true));
}
export function userSearchOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "user-search" }>,
"type" | "initialValue" | "required"
>
>,
) {
return id.optional().register(formRegistry, {
export function userSearchOptional(args: UserSearchArgs) {
return id.optional().register(formRegistry, userSearchMetadata(args, false));
}
function userSearchMetadata(args: UserSearchArgs, required: boolean) {
return {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "user-search",
type: "user-search" as const,
initialValue: null,
required: false,
});
required,
};
}
export function tournamentSearchOptional(
@@ -873,38 +856,30 @@ export function stageSelect(
});
}
export function weaponSelect(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "weapon-select" }>,
"type" | "initialValue" | "required"
>
>,
) {
return weaponSplId.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "weapon-select",
initialValue: null,
required: true,
});
type WeaponSelectArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "weapon-select" }>,
"type" | "initialValue" | "required"
>
>;
export function weaponSelect(args: WeaponSelectArgs) {
return weaponSplId.register(formRegistry, weaponSelectMetadata(args, true));
}
export function weaponSelectOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "weapon-select" }>,
"type" | "initialValue" | "required"
>
>,
) {
return weaponSplId.optional().register(formRegistry, {
export function weaponSelectOptional(args: WeaponSelectArgs) {
return weaponSplId
.optional()
.register(formRegistry, weaponSelectMetadata(args, false));
}
function weaponSelectMetadata(args: WeaponSelectArgs, required: boolean) {
return {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "weapon-select",
type: "weapon-select" as const,
initialValue: null,
required: false,
});
required,
};
}

View File

@@ -18,6 +18,7 @@ type ArrayFormFieldProps = Omit<FormFieldProps<"array">, "field"> & {
itemInitialValue?: unknown;
addable?: boolean;
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
disabled?: boolean;
};
export function ArrayFormField({
@@ -35,6 +36,7 @@ export function ArrayFormField({
itemInitialValue,
addable = true,
canRemoveItem,
disabled,
}: ArrayFormFieldProps) {
const { t } = useTranslation(["common"]);
const { translatedLabel, translatedBottomText, translatedError } =
@@ -93,6 +95,7 @@ export function ArrayFormField({
// so it shouldn't offer a remove button (you can't go below one visible row
// anyway). A lone edited row stays removable so the only item can be cleared.
const canRemoveAt = (index: number) =>
!disabled &&
(canRemoveItem ? canRemoveItem(value[index], index) : true) &&
count > min &&
(count > minVisible || !isPristineItem(value[index]));
@@ -112,7 +115,7 @@ export function ArrayFormField({
// Sorting is only offered for object arrays; primitive arrays are rendered
// inline without the fieldset header that carries the reorder controls.
const isSortable = Boolean(sortable) && isObjectArray;
const isSortable = Boolean(sortable) && isObjectArray && !disabled;
const handleMoveAt = (index: number, direction: 1 | -1) => {
const target = index + direction;
@@ -176,7 +179,7 @@ export function ArrayFormField({
variant="outlined"
icon={<Plus />}
onPress={handleAdd}
isDisabled={count >= max}
isDisabled={count >= max || disabled}
className="m-0-auto"
>
{t("common:actions.add")}

View File

@@ -8,6 +8,7 @@ type BadgesFormFieldProps = Omit<FormFieldProps<"badges">, "onBlur"> & {
onChange: (value: number[]) => void;
onBlur?: () => void;
options: BadgeOption[];
disabled?: boolean;
};
export function BadgesFormField({
@@ -20,6 +21,7 @@ export function BadgesFormField({
onChange,
onBlur,
options,
disabled,
}: BadgesFormFieldProps) {
const id = React.useId();
@@ -37,6 +39,7 @@ export function BadgesFormField({
onChange={onChange}
onBlur={onBlur}
maxCount={maxCount}
disabled={disabled}
/>
</FormFieldWrapper>
);

View File

@@ -12,6 +12,7 @@ type DatetimeFormFieldProps = Omit<
value: Date | undefined;
onChange: (value: Date | undefined) => void;
granularity?: "day" | "minute";
disabled?: boolean;
};
export function DatetimeFormField({
@@ -24,6 +25,7 @@ export function DatetimeFormField({
value,
onChange,
granularity = "minute",
disabled,
}: DatetimeFormFieldProps) {
const { translatedLabel, translatedError, translatedBottomText } =
useTranslatedTexts({ label, error, bottomText });
@@ -58,6 +60,7 @@ export function DatetimeFormField({
errorId={errorMessageId(name)}
bottomText={translatedBottomText}
isRequired={required}
isDisabled={disabled}
value={
value
? granularity === "day"

View File

@@ -12,6 +12,7 @@ type DualSelectFormFieldProps<V extends string> = Omit<
];
value: [V | null, V | null];
onChange: (value: [V | null, V | null]) => void;
disabled?: boolean;
};
export function DualSelectFormField<V extends string>({
@@ -22,6 +23,7 @@ export function DualSelectFormField<V extends string>({
value,
onChange,
fields,
disabled,
}: DualSelectFormFieldProps<V>) {
return (
<div className="stack xs">
@@ -33,6 +35,7 @@ export function DualSelectFormField<V extends string>({
onChange={(newValue) => onChange([newValue, value[1]])}
onBlur={onBlur}
clearable
disabled={disabled}
/>
<SelectFormField
label={fields[1].label}
@@ -41,6 +44,7 @@ export function DualSelectFormField<V extends string>({
onChange={(newValue) => onChange([value[0], newValue])}
onBlur={onBlur}
clearable
disabled={disabled}
/>
</div>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />

View File

@@ -10,6 +10,7 @@ type FieldsetFormFieldProps<S extends z.ZodRawShape> = Omit<
> & {
name: string;
fields: z.ZodObject<S>;
disabled?: boolean;
};
export function FieldsetFormField<S extends z.ZodRawShape>({
@@ -18,6 +19,7 @@ export function FieldsetFormField<S extends z.ZodRawShape>({
bottomText,
error,
fields,
disabled,
}: FieldsetFormFieldProps<S>) {
const fieldNames = Object.keys(fields.shape);
const { translatedLabel, translatedBottomText, translatedError } =
@@ -33,6 +35,7 @@ export function FieldsetFormField<S extends z.ZodRawShape>({
key={fieldName}
name={`${name}.${fieldName}`}
field={fields.shape[fieldName] as z.ZodType}
disabled={disabled}
/>
))}
{translatedError ? (

Some files were not shown because too many files have changed in this diff Show More