From 46e469f81a4124a8a6b75d5dfabb465a098f6c51 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:09:22 +0300 Subject: [PATCH] SendouForm migration finalized (#3263) --- app/components/FriendCodeInput.tsx | 88 ++-- app/components/StageSelect.tsx | 3 + app/features/admin/actions/admin.server.ts | 79 +-- app/features/admin/admin-constants.ts | 2 + app/features/admin/admin-schemas.ts | 116 ++++- app/features/admin/routes/admin.test.ts | 11 +- app/features/admin/routes/admin.tsx | 366 +++++--------- .../associations/associations-schemas.ts | 4 +- .../badges/components/BadgesSelector.tsx | 8 +- .../calendar.$id.report-winners.server.ts | 25 +- app/features/calendar/actions/calendar.tsx | 14 +- app/features/calendar/calendar-constants.ts | 2 + app/features/calendar/calendar-new-schemas.ts | 11 +- app/features/calendar/calendar-schemas.ts | 159 ++++-- .../routes/calendar.$id.report-winners.tsx | 429 +++++----------- .../form-examples-schema.ts | 16 +- .../components-showcase/routes/components.tsx | 10 +- app/features/lfg/lfg-schemas.ts | 4 +- .../plus-suggestions-schemas.ts | 6 +- .../scrims/actions/scrims.$id.server.ts | 10 +- app/features/scrims/actions/scrims.server.ts | 35 +- app/features/scrims/routes/scrims.new.tsx | 29 +- app/features/scrims/scrims-schemas.ts | 15 +- .../sendouq/actions/q.looking.server.ts | 12 +- app/features/sendouq/actions/q.server.ts | 18 +- .../sendouq/components/GroupCard.module.css | 4 - app/features/sendouq/components/GroupCard.tsx | 59 +-- app/features/sendouq/q-constants.ts | 6 +- app/features/sendouq/q-schemas.server.ts | 25 +- app/features/sendouq/q-schemas.ts | 35 ++ .../settings/actions/settings.server.ts | 11 +- .../settings/components/LocaleTab.tsx | 2 +- .../settings/components/PreferencesTab.tsx | 6 +- app/features/settings/settings-schemas.ts | 4 +- app/features/team/team-schemas.ts | 6 +- .../tournament-admin-staff-schemas.ts | 10 +- .../actions/to.$id.looking.server.ts | 15 +- .../actions/org.$slug.edit.server.ts | 8 +- .../actions/org.$slug.server.ts | 15 +- .../routes/org.$slug.tsx | 2 +- .../tournament-organization-schemas.ts | 8 +- .../components/AddPrivateNoteDialog.tsx | 136 +---- .../user-card/routes/user-card.$id.note.ts | 15 +- app/features/user-card/user-card-schemas.ts | 43 +- .../actions/u.$identifier.admin.server.ts | 15 +- .../components/WidgetSettingsForm.tsx | 78 +-- .../core/widgets/widget-form-schemas.ts | 70 ++- app/features/user-page/user-page-schemas.ts | 8 +- .../user-report/user-report-schemas.ts | 4 +- .../vods/routes/vods.new.browser.test.tsx | 14 +- app/features/vods/routes/vods.new.tsx | 82 +-- app/features/vods/vods-schemas.ts | 13 +- app/form/FormField.tsx | 72 ++- app/form/SendouForm.browser.test.tsx | 110 ++-- app/form/SendouForm.tsx | 214 +++++--- app/form/UnsavedChangesGuard.tsx | 84 +++ app/form/fields.ts | 477 +++++++++--------- app/form/fields/ArrayFormField.tsx | 7 +- app/form/fields/BadgesFormField.tsx | 3 + app/form/fields/DatetimeFormField.tsx | 3 + app/form/fields/DualSelectFormField.tsx | 4 + app/form/fields/FieldsetFormField.tsx | 3 + app/form/fields/FormFieldWrapper.tsx | 13 +- app/form/fields/InGameNameFormField.tsx | 2 +- app/form/fields/InputFormField.tsx | 13 +- app/form/fields/InputGroupFormField.tsx | 11 +- app/form/fields/SelectFormField.tsx | 9 +- app/form/fields/StageSelectFormField.tsx | 3 + app/form/fields/TeamSearchFormField.tsx | 3 + app/form/fields/TextareaFormField.tsx | 16 +- app/form/fields/TimeRangeFormField.tsx | 4 + app/form/fields/TournamentSearchFormField.tsx | 3 + app/form/fields/UserSearchFormField.tsx | 3 + app/form/fields/WeaponPoolFormField.tsx | 11 +- app/form/fields/WeaponSelectFormField.tsx | 3 + app/form/index.ts | 8 +- app/form/types.ts | 35 +- app/form/utils.ts | 27 +- app/root.tsx | 2 + app/utils/remix.server.ts | 39 -- app/utils/zod.ts | 6 - docs/dev/forms.md | 50 +- e2e/ban.spec.ts | 36 +- e2e/calendar.spec.ts | 51 ++ locales/da/calendar.json | 7 - locales/da/forms.json | 37 +- locales/da/q.json | 6 - locales/da/scrims.json | 1 - locales/da/user.json | 1 - locales/de/calendar.json | 7 - locales/de/forms.json | 37 +- locales/de/q.json | 6 - locales/de/scrims.json | 1 - locales/de/user.json | 1 - locales/en/calendar.json | 7 - locales/en/forms.json | 37 +- locales/en/q.json | 6 - locales/en/scrims.json | 1 - locales/en/user.json | 1 - locales/es-ES/calendar.json | 7 - locales/es-ES/forms.json | 41 +- locales/es-ES/q.json | 6 - locales/es-ES/scrims.json | 1 - locales/es-ES/user.json | 1 - locales/es-US/calendar.json | 7 - locales/es-US/forms.json | 39 +- locales/es-US/q.json | 6 - locales/es-US/scrims.json | 1 - locales/es-US/user.json | 1 - locales/fr-CA/calendar.json | 7 - locales/fr-CA/forms.json | 37 +- locales/fr-CA/q.json | 6 - locales/fr-CA/scrims.json | 1 - locales/fr-CA/user.json | 1 - locales/fr-EU/calendar.json | 7 - locales/fr-EU/forms.json | 39 +- locales/fr-EU/q.json | 6 - locales/fr-EU/scrims.json | 1 - locales/fr-EU/user.json | 1 - locales/he/calendar.json | 7 - locales/he/forms.json | 37 +- locales/he/q.json | 6 - locales/he/scrims.json | 1 - locales/he/user.json | 1 - locales/it/calendar.json | 7 - locales/it/forms.json | 39 +- locales/it/q.json | 6 - locales/it/scrims.json | 1 - locales/it/user.json | 1 - locales/ja/calendar.json | 7 - locales/ja/forms.json | 39 +- locales/ja/q.json | 6 - locales/ja/scrims.json | 1 - locales/ja/user.json | 1 - locales/ko/calendar.json | 7 - locales/ko/forms.json | 37 +- locales/ko/q.json | 6 - locales/ko/scrims.json | 1 - locales/ko/user.json | 1 - locales/nl/calendar.json | 7 - locales/nl/forms.json | 37 +- locales/nl/q.json | 6 - locales/nl/scrims.json | 1 - locales/nl/user.json | 1 - locales/pl/calendar.json | 7 - locales/pl/forms.json | 37 +- locales/pl/q.json | 6 - locales/pl/scrims.json | 1 - locales/pl/user.json | 1 - locales/pt-BR/calendar.json | 7 - locales/pt-BR/forms.json | 39 +- locales/pt-BR/q.json | 6 - locales/pt-BR/scrims.json | 1 - locales/pt-BR/user.json | 1 - locales/ru/calendar.json | 7 - locales/ru/forms.json | 39 +- locales/ru/q.json | 6 - locales/ru/scrims.json | 1 - locales/ru/user.json | 1 - locales/zh/calendar.json | 7 - locales/zh/forms.json | 37 +- locales/zh/q.json | 6 - locales/zh/scrims.json | 1 - locales/zh/user.json | 1 - 164 files changed, 2321 insertions(+), 2018 deletions(-) create mode 100644 app/features/sendouq/q-schemas.ts create mode 100644 app/form/UnsavedChangesGuard.tsx diff --git a/app/components/FriendCodeInput.tsx b/app/components/FriendCodeInput.tsx index 7b49ed8b5..5ec7aff81 100644 --- a/app/components/FriendCodeInput.tsx +++ b/app/components/FriendCodeInput.tsx @@ -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
SW-{friendCode}
; + } return ( - - -
-
- {!friendCode ? ( -
- - -
-
- {t("common:fc.whereToFind")} -
- {t("common:fc.whereToFind")} + + {({ FormField }) => ( +
+ +
+ {t("common:fc.onceSetStaffOnly")} + +
+
+ {t("common:fc.whereToFind")}
- -
- ) : null} - {friendCode ? ( -
SW-{friendCode}
- ) : ( - - )} + {t("common:fc.whereToFind")} +
+ +
- {!friendCode ? ( - - {t("common:actions.save")} - - ) : null} -
- {!friendCode ? ( -
- {t("common:fc.onceSetStaffOnly")} -
- ) : null} - + )} + ); } diff --git a/app/components/StageSelect.tsx b/app/components/StageSelect.tsx index a25dddc68..058afc27c 100644 --- a/app/components/StageSelect.tsx +++ b/app/components/StageSelect.tsx @@ -16,6 +16,7 @@ interface StageSelectProps { clearable?: Clearable; testId?: string; isRequired?: boolean; + isDisabled?: boolean; } export function StageSelect({ @@ -26,6 +27,7 @@ export function StageSelect({ clearable, testId = "stage-select", isRequired, + isDisabled, }: StageSelectProps) { const { t } = useTranslation(["common", "game-misc"]); const items = useStageItems(); @@ -54,6 +56,7 @@ export function StageSelect({ clearable={clearable} data-testid={testId} isRequired={isRequired} + isDisabled={isDisabled} > {({ id, name }) => ( diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index f3c50e7f5..45a8d8092 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -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()), - }), -]); diff --git a/app/features/admin/admin-constants.ts b/app/features/admin/admin-constants.ts index 0ddc89efe..3a72e5175 100644 --- a/app/features/admin/admin-constants.ts +++ b/app/features/admin/admin-constants.ts @@ -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; diff --git a/app/features/admin/admin-schemas.ts b/app/features/admin/admin-schemas.ts index bc587733e..6a6a4b1a5 100644 --- a/app/features/admin/admin-schemas.ts +++ b/app/features/admin/admin-schemas.ts @@ -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({ diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts index 1478a6314..7b094edff 100644 --- a/app/features/admin/routes/admin.test.ts +++ b/app/features/admin/routes/admin.test.ts @@ -12,10 +12,13 @@ import { withUserId, wrappedAction, } from "~/utils/Test"; -import type { adminActionSchema } from "../actions/admin.server"; +import type { adminActionSchema } from "../admin-schemas"; import { action } from "./admin"; -const adminAction = wrappedAction({ action }); +const adminAction = wrappedAction({ + action, + isJsonSubmission: true, +}); const voteArgs = ({ score, @@ -305,8 +308,8 @@ const migrateUserAction = () => adminAction( { _action: "MIGRATE", - "old-user": 1, - "new-user": 2, + oldUser: 1, + newUser: 2, }, { user: "admin" }, ); diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index 829987e39..cf8d87b41 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -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"; @@ -20,12 +18,10 @@ 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 { SEED_VARIATIONS } from "~/features/api-private/constants"; -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 { @@ -35,6 +31,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"; @@ -60,7 +70,7 @@ export default function AdminPage() { } return ( -
+
Actions @@ -80,28 +90,17 @@ export default function AdminPage() { function FriendCodeLookUp() { const data = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); - const [friendCode, setFriendCode] = React.useState( - searchParams.get("friendCode") ?? "", - ); - const fetcher = useFetcher(); return ( -
-
- setFriendCode(e.target.value)} - /> - } - onPress={() => setSearchParams({ friendCode })} - > - Search - -
+
+ setSearchParams({ friendCode })} + > + {({ FormField }) => } +
{data.friendCodeSearchUsers?.map((user) => ( (); - const [newUserId, setNewUserId] = React.useState(); - const navigation = useNavigation(); - const fetcher = useFetcher(); - return ( - -

Migrate user data

-
-
- setOldUserId(newUser?.id)} - /> -
-
- setNewUserId(newUser?.id)} - /> -
-
-
- - Migrate - -
- - Note: data on "New user" will be deleted (e.g. builds) - -
+ + {({ FormField }) => ( + <> + + + + )} + ); } function LinkPlayer() { - const fetcher = useFetcher(); - return ( - -

Link player

-
-
- -
-
- - -
-
-
- - Link player - -
-
+ + {({ FormField }) => ( + <> + + + + )} + ); } function GiveArtist() { - const fetcher = useFetcher(); - return ( - -

Add as artist

-
- -
-
- - Add as artist - -
-
+ + {({ FormField }) => } + ); } function GiveVideoAdder() { - const fetcher = useFetcher(); - return ( - -

Give video adder

-
- -
-
- - Add as video adder - -
-
+ + {({ FormField }) => } + ); } function GiveTournamentOrganizer() { - const fetcher = useFetcher(); - return ( - -

Give tournament organizer

- -
- - Add as tournament organizer - -
-
+ + {({ FormField }) => } + ); } function GiveApiAccess() { - const fetcher = useFetcher(); - return ( - -

Give API access

- -
- - Grant API access - -
-
+ + {({ FormField }) => } + ); } function UpdateFriendCode() { - const fetcher = useFetcher(); - const id = React.useId(); - return ( - -

Update friend code

-
-
- -
-
- - -
-
-
- - Submit - -
-
+ + {({ FormField }) => ( + <> + + + + )} + ); } function ForcePatron() { - const fetcher = useFetcher(); - return ( - -

Force patron

-
-
- -
- -
- - -
- -
- - -
-
-
- - Save - -
-
+ + {({ FormField }) => ( + <> + + + + + )} + ); } function BanUser() { - const fetcher = useFetcher(); - return ( - -

Ban user

-
-
- -
- -
- - -
- -
- - -
-
-
- - Save - -
-
+ Ban user} + submitButtonText="Save" + > + {({ FormField }) => ( + <> + + + + + )} + ); } function UnbanUser() { - const fetcher = useFetcher(); - return ( - -

Unban user

- -
- - Save - -
-
+ Unban user} + submitButtonText="Save" + > + {({ FormField }) => } + ); } function RefreshPlusTiers() { - const fetcher = useFetcher(); - return ( - -

Refresh Plus Tiers

- - Refresh - -
+ + {null} + ); } diff --git a/app/features/associations/associations-schemas.ts b/app/features/associations/associations-schemas.ts index 288dde12f..104c53b81 100644 --- a/app/features/associations/associations-schemas.ts +++ b/app/features/associations/associations-schemas.ts @@ -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, }), diff --git a/app/features/badges/components/BadgesSelector.tsx b/app/features/badges/components/BadgesSelector.tsx index 13f312ba3..7fe2ff39d 100644 --- a/app/features/badges/components/BadgesSelector.tsx +++ b/app/features/badges/components/BadgesSelector.tsx @@ -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" > diff --git a/app/features/calendar/actions/calendar.$id.report-winners.server.ts b/app/features/calendar/actions/calendar.$id.report-winners.server.ts index d491e4752..f09aaa0c0 100644 --- a/app/features/calendar/actions/calendar.$id.report-winners.server.ts +++ b/app/features/calendar/actions/calendar.$id.report-winners.server.ts @@ -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)); diff --git a/app/features/calendar/actions/calendar.tsx b/app/features/calendar/actions/calendar.tsx index fb88e06a2..cca7513ab 100644 --- a/app/features/calendar/actions/calendar.tsx +++ b/app/features/calendar/actions/calendar.tsx @@ -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({ diff --git a/app/features/calendar/calendar-constants.ts b/app/features/calendar/calendar-constants.ts index 65c74de6f..adb183f60 100644 --- a/app/features/calendar/calendar-constants.ts +++ b/app/features/calendar/calendar-constants.ts @@ -94,6 +94,8 @@ export const EXCLUDED_TAGS: Array = ["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, diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts index 66d86d548..4b332e0c5 100644 --- a/app/features/calendar/calendar-new-schemas.ts +++ b/app/features/calendar/calendar-new-schemas.ts @@ -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, diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index a65b72c5d..5a7d9d8db 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -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; + +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 => { + 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 diff --git a/app/features/calendar/routes/calendar.$id.report-winners.tsx b/app/features/calendar/routes/calendar.$id.report-winners.tsx index 6f507f4b3..4d0417375 100644 --- a/app/features/calendar/routes/calendar.$id.report-winners.tsx +++ b/app/features/calendar/routes/calendar.$id.report-winners.tsx @@ -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(); return (
-
-

- {t("calendar:forms.reportResultsHeader", { eventName: data.name })} -

- - - {t("calendar:forms.reportResultsInfo")} - - - - {t("common:actions.submit")} - - - + ({ + teamName: team.teamName, + placement: team.placement, + players: team.players.map(playerToFormValue), + })), + }} + > + {({ FormField }) => ( + <> + + + {t("calendar:forms.reportResultsInfo")} + + + {({ itemName }: ArrayItemRenderContext) => ( +
+ + + + {(props: CustomFieldRenderProps) => ( + + )} + +
+ )} +
+ + )} +
); } -function ParticipantsCountInput() { - const { t } = useTranslation("calendar"); - const data = useLoaderData(); +type LoadedPlayer = Unpacked< + Unpacked["winners"]>["players"] +>; - return ( -
- - -
- ); +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(); - 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; - const handleTeamDelete = () => { - setAmountOfTeams(amountOfTeams - 1); + const handlePlayerChange = (index: number, newPlayer: ReportedPlayer) => { + onChange(players.map((player, i) => (i === index ? newPlayer : player))); }; - return ( - <> -
- {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 ( - - 1 - ? handleTeamDelete - : undefined - } - hidden={hidden} - initialPlacement={String(i + 1)} - initialValues={data.winners[i]} - /> - {!hidden &&
} -
- ); - })} - setAmountOfTeams((amountOfTeams) => amountOfTeams + 1)} - size="small" - > - {t("forms.team.add")} - - - ); -} - -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["winners"]>; -}) { - const { t } = useTranslation("calendar"); - const teamNameId = React.useId(); - const placementId = React.useId(); - - const [results, setResults] = React.useState({ - 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) => { - setResults({ ...results, teamName: e.target.value }); - }; - - const handlePlacementChange = (e: React.ChangeEvent) => { - setResults({ ...results, placement: e.target.value }); - }; - - const handleSetPlayers = React.useCallback( - (action: React.SetStateAction) => { - setResults((prev) => ({ - ...prev, - players: typeof action === "function" ? action(prev.players) : action, - })); - }, - [], - ); - - if (hidden) return null; - - return ( -
- - (typeof player === "string" && player !== "") || - (typeof player === "object" && player.id !== 0), - ), - })} - /> -
-
- - -
-
- - -
-
- - {onRemoveTeam && ( - - {t("forms.team.remove")} - - )} -
- ); -} - -function Players({ - players, - setPlayers, -}: { - players: TeamResults["players"]; - setPlayers: React.Dispatch>; -}) { - 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 (
- {players.map((player, i) => { - const formId = `player-${i + 1}`; - const asPlainInput = typeof player === "string"; - - return ( -
-
- - handlePlayerInputTypeChange(i)} - > - {asPlainInput - ? t("forms.team.player.addAsUser") - : t("forms.team.player.addAsText")} - -
- -
- ); - })} + {players.map((player, i) => ( + + ))} + {translatedError ? ( + + {translatedError} + + ) : null}
onChange([...players, EMPTY_REPORTED_PLAYER])} isDisabled={ players.length === CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH } - variant="outlined" > - {t("forms.team.player.add")} - {" "} + {t("calendar:forms.team.player.add")} + onChange(players.slice(0, -1))} isDisabled={players.length === 1} > - {t("forms.team.player.remove")} + {t("calendar:forms.team.player.remove")}
@@ -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) => { - 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 ( - - ); - } + const asPlainInput = player.type === "NAME"; + const label = t("calendar:forms.team.player.header", { number: index + 1 }); return ( - +
+
+ {player.type === "NAME" ? ( + <> + + + onPlayerChange(index, { type: "NAME", name: e.target.value }) + } + maxLength={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH} + /> + + ) : ( + + onPlayerChange(index, { type: "USER", id: user?.id ?? null }) + } + /> + )} +
+ + onPlayerChange( + index, + asPlainInput ? EMPTY_REPORTED_PLAYER : { type: "NAME", name: "" }, + ) + } + > + {asPlainInput + ? t("calendar:forms.team.player.addAsUser") + : t("calendar:forms.team.player.addAsText")} + +
); } diff --git a/app/features/components-showcase/form-examples-schema.ts b/app/features/components-showcase/form-examples-schema.ts index dadbc6214..540b3104a 100644 --- a/app/features/components-showcase/form-examples-schema.ts +++ b/app/features/components-showcase/form-examples-schema.ts @@ -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({ diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index b30e6a8f4..a989b7621 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -2102,14 +2102,14 @@ function FormFieldsSection({ id }: { id: string }) { {({ FormField }) => (
Text Fields - + @@ -2123,7 +2123,7 @@ function FormFieldsSection({ id }: { id: string }) { Text Areas - + @@ -2173,7 +2173,7 @@ function FormFieldsSection({ id }: { id: string }) { Date & Time - + @@ -2181,7 +2181,7 @@ function FormFieldsSection({ id }: { id: string }) { - + diff --git a/app/features/lfg/lfg-schemas.ts b/app/features/lfg/lfg-schemas.ts index 00e68beef..92b1f4a5f 100644 --- a/app/features/lfg/lfg-schemas.ts +++ b/app/features/lfg/lfg-schemas.ts @@ -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, }), diff --git a/app/features/plus-suggestions/plus-suggestions-schemas.ts b/app/features/plus-suggestions/plus-suggestions-schemas.ts index 0a8f4de32..fab49f152 100644 --- a/app/features/plus-suggestions/plus-suggestions-schemas.ts +++ b/app/features/plus-suggestions/plus-suggestions-schemas.ts @@ -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, }); diff --git a/app/features/scrims/actions/scrims.$id.server.ts b/app/features/scrims/actions/scrims.$id.server.ts index bd1aa7e26..c6b121011 100644 --- a/app/features/scrims/actions/scrims.$id.server.ts +++ b/app/features/scrims/actions/scrims.$id.server.ts @@ -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) { diff --git a/app/features/scrims/actions/scrims.server.ts b/app/features/scrims/actions/scrims.server.ts index 41c32017d..687ac7099 100644 --- a/app/features/scrims/actions/scrims.server.ts +++ b/app/features/scrims/actions/scrims.server.ts @@ -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({ - 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({ - msg: "Selected time must be one of the available options", - field: "at", - }); + return { + fieldErrors: { + at: "Selected time must be one of the available options", + }, + }; } } diff --git a/app/features/scrims/routes/scrims.new.tsx b/app/features/scrims/routes/scrims.new.tsx index 1056859f6..b00b45f87 100644 --- a/app/features/scrims/routes/scrims.new.tsx +++ b/app/features/scrims/routes/scrims.new.tsx @@ -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() { - + @@ -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 ( - - - setValue("mapsTournamentId", tournament?.id ?? null) - } - /> - - ); + return ; } diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts index 257a297b6..a52a7af51 100644 --- a/app/features/scrims/scrims-schemas.ts +++ b/app/features/scrims/scrims-schemas.ts @@ -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) { diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index 2796e51a4..b0ec1ae12 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -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; diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index e02f1046d..13de87791 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -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, }); diff --git a/app/features/sendouq/components/GroupCard.module.css b/app/features/sendouq/components/GroupCard.module.css index dbf09540d..d964e50b5 100644 --- a/app/features/sendouq/components/GroupCard.module.css +++ b/app/features/sendouq/components/GroupCard.module.css @@ -81,10 +81,6 @@ height: 24px; } -.noteTextarea { - height: 4rem !important; -} - .futureMatchMode { border-radius: 100%; background-color: var(--color-bg); diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 136eac151..7d1c23ab3 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -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 ( setEditing(false)} /> @@ -408,31 +406,18 @@ function AddPrivateNoteForm({ note?: string | null; stopEditing: () => void; }) { - const fetcher = useFetcher(); - const textareaRef = React.useRef(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 ( - -