From c1cc82c80722f47c1f54f09b601506f84aa1de21 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:12:04 +0200 Subject: [PATCH] Migrate /u/:identifier/edit to SendouForm, fix bad IGN (#2849) --- app/components/CustomizedColorsInput.tsx | 35 +- .../actions/u.$identifier.edit.server.ts | 118 ++-- .../routes/u.$identifier.edit.test.ts | 45 +- .../user-page/routes/u.$identifier.edit.tsx | 653 ++++-------------- .../user-page/user-page-constants.test.ts | 39 ++ app/features/user-page/user-page-constants.ts | 2 + .../user-page/user-page-schemas.server.ts | 25 +- app/features/user-page/user-page-schemas.ts | 244 ++++--- app/form/FormField.tsx | 9 +- app/form/SendouForm.tsx | 1 + app/form/fields/InputFormField.tsx | 5 +- app/form/fields/SelectFormField.module.css | 3 + app/form/fields/SelectFormField.tsx | 86 ++- app/form/fields/TextareaFormField.tsx | 3 + app/form/types.ts | 6 + app/styles/u.$identifier.module.css | 24 - app/utils/sql.ts | 4 - app/utils/zod.ts | 2 +- e2e/scrims.spec.ts | 1 - e2e/user-page.spec.ts | 33 +- locales/da/forms.json | 34 +- locales/da/user.json | 26 - locales/de/forms.json | 34 +- locales/de/user.json | 26 - locales/en/forms.json | 30 +- locales/en/user.json | 26 - locales/es-ES/forms.json | 34 +- locales/es-ES/user.json | 26 - locales/es-US/forms.json | 34 +- locales/es-US/user.json | 26 - locales/fr-CA/forms.json | 32 +- locales/fr-CA/user.json | 26 - locales/fr-EU/forms.json | 32 +- locales/fr-EU/user.json | 26 - locales/he/forms.json | 32 +- locales/he/user.json | 26 - locales/it/forms.json | 34 +- locales/it/user.json | 26 - locales/ja/forms.json | 34 +- locales/ja/user.json | 26 - locales/ko/forms.json | 32 +- locales/ko/user.json | 26 - locales/nl/forms.json | 32 +- locales/nl/user.json | 26 - locales/pl/forms.json | 32 +- locales/pl/user.json | 26 - locales/pt-BR/forms.json | 32 +- locales/pt-BR/user.json | 26 - locales/ru/forms.json | 32 +- locales/ru/user.json | 26 - locales/zh/forms.json | 32 +- locales/zh/user.json | 26 - scripts/fix-in-game-names.ts | 29 + 53 files changed, 1062 insertions(+), 1243 deletions(-) create mode 100644 app/features/user-page/user-page-constants.test.ts create mode 100644 app/form/fields/SelectFormField.module.css create mode 100644 scripts/fix-in-game-names.ts diff --git a/app/components/CustomizedColorsInput.tsx b/app/components/CustomizedColorsInput.tsx index d47a28b42..db6279b52 100644 --- a/app/components/CustomizedColorsInput.tsx +++ b/app/components/CustomizedColorsInput.tsx @@ -28,12 +28,16 @@ type ContrastArray = { export function CustomizedColorsInput({ initialColors, + value: controlledValue, + onChange, }: { initialColors?: Record | null; + value?: Record | null; + onChange?: (value: Record | null) => void; }) { const { t } = useTranslation(); const [colors, setColors] = React.useState( - initialColors ?? {}, + controlledValue ?? initialColors ?? {}, ); const [defaultColors, setDefaultColors] = React.useState< @@ -41,6 +45,15 @@ export function CustomizedColorsInput({ >([]); const [contrasts, setContrast] = React.useState([]); + const updateColors = (newColors: CustomColorsRecord) => { + setColors(newColors); + if (onChange) { + const filtered = colorsWithDefaultsFilteredOut(newColors, defaultColors); + const hasValues = Object.keys(filtered).length > 0; + onChange(hasValues ? (filtered as Record) : null); + } + }; + useDebounce( () => { for (const color in colors) { @@ -79,13 +92,15 @@ export function CustomizedColorsInput({
- + {!onChange ? ( + + ) : null}
{CUSTOM_CSS_VAR_COLORS.filter( (cssVar) => cssVar !== "bg-lightest", @@ -102,7 +117,7 @@ export function CustomizedColorsInput({ if (cssVar === "bg-lighter") { extras["bg-lightest"] = `${e.target.value}80`; } - setColors({ + updateColors({ ...colors, ...extras, [cssVar]: e.target.value, @@ -122,7 +137,7 @@ export function CustomizedColorsInput({ (color) => color["bg-lightest"], )?.["bg-lightest"]; } - setColors({ + updateColors({ ...newColors, [cssVar]: defaultColors.find((color) => color[cssVar])?.[ cssVar diff --git a/app/features/user-page/actions/u.$identifier.edit.server.ts b/app/features/user-page/actions/u.$identifier.edit.server.ts index 04a955e05..aa2b85a0a 100644 --- a/app/features/user-page/actions/u.$identifier.edit.server.ts +++ b/app/features/user-page/actions/u.$identifier.edit.server.ts @@ -1,79 +1,85 @@ import { type ActionFunction, redirect } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; +import { BADGE } from "~/features/badges/badges-constants"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { safeParseRequestFormData } from "~/utils/remix.server"; -import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql"; +import { parseFormData } from "~/form/parse.server"; import { userPage } from "~/utils/urls"; -import { userEditActionSchema } from "../user-page-schemas"; +import { userEditProfileSchemaServer } from "../user-page-schemas.server"; export const action: ActionFunction = async ({ request }) => { - const parsedInput = await safeParseRequestFormData({ + const user = requireUser(); + + const result = await parseFormData({ request, - schema: userEditActionSchema, + schema: userEditProfileSchemaServer, }); - if (!parsedInput.success) { - return { - errors: parsedInput.errors, - }; + if (!result.success) { + return { fieldErrors: result.fieldErrors }; } - const { - inGameNameText, - inGameNameDiscriminator, - newProfileEnabled, - ...data - } = parsedInput.data; + const data = result.data; - const user = requireUser(); - const inGameName = - inGameNameText && inGameNameDiscriminator - ? `${inGameNameText}#${inGameNameDiscriminator}` + const [subjectPronoun, objectPronoun] = data.pronouns ?? [null, null]; + const pronouns = + subjectPronoun && objectPronoun + ? JSON.stringify({ subject: subjectPronoun, object: objectPronoun }) : null; - try { - const pronouns = - data.subjectPronoun && data.objectPronoun - ? JSON.stringify({ - subject: data.subjectPronoun, - object: data.objectPronoun, - }) - : null; + const [motionSens, stickSens] = data.sensitivity ?? [null, null]; - const editedUser = await UserRepository.updateProfile({ - ...data, - pronouns, - inGameName, - userId: user.id, - }); + const weapons = data.weapons.map((w) => ({ + weaponSplId: w.id, + isFavorite: w.isFavorite ? (1 as const) : (0 as const), + })); - await UserRepository.updatePreferences(user.id, { - newProfileEnabled: Boolean(newProfileEnabled), - }); + const css = data.css ? JSON.stringify(data.css) : null; - // TODO: to transaction - if (inGameName) { - const tournamentIdsAffected = - await TournamentTeamRepository.updateMemberInGameNameForNonStarted({ - inGameName, - userId: user.id, - }); + const isSupporter = user.roles?.includes("SUPPORTER"); + const isArtist = user.roles?.includes("ARTIST"); - for (const tournamentId of tournamentIdsAffected) { - clearTournamentDataCache(tournamentId); - } + const maxBadgeCount = isSupporter + ? BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1 + : 1; + const limitedBadgeIds = data.favoriteBadgeIds.slice(0, maxBadgeCount); + + const editedUser = await UserRepository.updateProfile({ + userId: user.id, + country: data.country, + bio: data.bio, + customUrl: data.customUrl, + customName: data.customName, + motionSens: motionSens !== null ? Number(motionSens) : null, + stickSens: stickSens !== null ? Number(stickSens) : null, + pronouns, + inGameName: data.inGameName, + css: isSupporter ? css : null, + battlefy: data.battlefy, + weapons, + favoriteBadgeIds: limitedBadgeIds.length > 0 ? limitedBadgeIds : null, + showDiscordUniqueName: data.showDiscordUniqueName ? 1 : 0, + commissionsOpen: isArtist && data.commissionsOpen ? 1 : 0, + commissionText: isArtist ? data.commissionText : null, + }); + + await UserRepository.updatePreferences(user.id, { + newProfileEnabled: isSupporter ? data.newProfileEnabled : false, + }); + + // TODO: to transaction + if (data.inGameName) { + const tournamentIdsAffected = + await TournamentTeamRepository.updateMemberInGameNameForNonStarted({ + inGameName: data.inGameName, + userId: user.id, + }); + + for (const tournamentId of tournamentIdsAffected) { + clearTournamentDataCache(tournamentId); } - - throw redirect(userPage(editedUser)); - } catch (e) { - if (!errorIsSqliteUniqueConstraintFailure(e)) { - throw e; - } - - return { - errors: ["forms.errors.invalidCustomUrl.duplicate"], - }; } + + throw redirect(userPage(editedUser)); }; diff --git a/app/features/user-page/routes/u.$identifier.edit.test.ts b/app/features/user-page/routes/u.$identifier.edit.test.ts index 109367508..20bf6ca7d 100644 --- a/app/features/user-page/routes/u.$identifier.edit.test.ts +++ b/app/features/user-page/routes/u.$identifier.edit.test.ts @@ -1,33 +1,30 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test"; -import type { userEditActionSchema } from "../user-page-schemas"; +import type { userEditProfileBaseSchema } from "../user-page-schemas"; import { action as editUserProfileAction } from "./u.$identifier.edit"; -const action = wrappedAction({ +const action = wrappedAction({ action: editUserProfileAction, + isJsonSubmission: true, }); const DEFAULT_FIELDS = { battlefy: null, bio: null, - commissionsOpen: 1, + commissionsOpen: false, commissionText: null, country: "FI", customName: null, customUrl: null, - favoriteBadgeIds: null, - inGameNameDiscriminator: null, - inGameNameText: null, - motionSens: null, - showDiscordUniqueName: 1, - newProfileEnabled: 0, - stickSens: null, - subjectPronoun: null, - objectPronoun: null, - weapons: JSON.stringify([ - { weaponSplId: 1 as MainWeaponId, isFavorite: 0 }, - ]) as any, + favoriteBadgeIds: [], + inGameName: null, + sensitivity: [null, null] as [null, null], + pronouns: [null, null] as [null, null], + weapons: [{ id: 1 as MainWeaponId, isFavorite: false }], + showDiscordUniqueName: true, + newProfileEnabled: false, + css: null, }; describe("user page editing", () => { @@ -41,8 +38,8 @@ describe("user page editing", () => { it("adds valid custom css vars", async () => { const response = await action( { - css: JSON.stringify({ bg: "#fff" }), ...DEFAULT_FIELDS, + css: { bg: "#fff" }, }, { user: "regular", params: { identifier: "2" } }, ); @@ -53,28 +50,28 @@ describe("user page editing", () => { it("prevents adding custom css var of unknown property", async () => { const res = await action( { - css: JSON.stringify({ - "backdrop-filter": "#fff", - }), ...DEFAULT_FIELDS, + css: { + "backdrop-filter": "#fff", + } as any, }, { user: "regular", params: { identifier: "2" } }, ); - expect(res.errors[0]).toBe("Invalid custom CSS var object"); + expect(res.fieldErrors.css).toBeDefined(); }); it("prevents adding custom css var of unknown value", async () => { const res = await action( { - css: JSON.stringify({ - bg: "url(https://sendou.ink/u?q=1&_data=features%2Fuser-search%2Froutes%2Fu)", - }), ...DEFAULT_FIELDS, + css: { + bg: "url(https://sendou.ink/u?q=1&_data=features%2Fuser-search%2Froutes%2Fu)", + }, }, { user: "regular", params: { identifier: "2" } }, ); - expect(res.errors[0]).toBe("Invalid custom CSS var object"); + expect(res.fieldErrors.css).toBeDefined(); }); }); diff --git a/app/features/user-page/routes/u.$identifier.edit.tsx b/app/features/user-page/routes/u.$identifier.edit.tsx index 7dd80fd31..cf373ceef 100644 --- a/app/features/user-page/routes/u.$identifier.edit.tsx +++ b/app/features/user-page/routes/u.$identifier.edit.tsx @@ -1,37 +1,26 @@ -import clsx from "clsx"; -import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; -import { Form, Link, useLoaderData, useMatches } from "react-router"; +import { Link, useLoaderData, useMatches } from "react-router"; import { CustomizedColorsInput } from "~/components/CustomizedColorsInput"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; -import { SendouSwitch } from "~/components/elements/Switch"; -import { FormErrors } from "~/components/FormErrors"; import { FormMessage } from "~/components/FormMessage"; -import { WeaponImage } from "~/components/Image"; -import { Input } from "~/components/Input"; -import { StarIcon } from "~/components/icons/Star"; -import { StarFilledIcon } from "~/components/icons/StarFilled"; -import { TrashIcon } from "~/components/icons/Trash"; -import { Label } from "~/components/Label"; -import { SubmitButton } from "~/components/SubmitButton"; -import { WeaponSelect } from "~/components/WeaponSelect"; -import { OBJECT_PRONOUNS, SUBJECT_PRONOUNS, type Tables } from "~/db/tables"; import { BADGE } from "~/features/badges/badges-constants"; -import { BadgesSelector } from "~/features/badges/components/BadgesSelector"; +import type { CustomFieldRenderProps } from "~/form/FormField"; +import { SendouForm } from "~/form/SendouForm"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useHasRole } from "~/modules/permissions/hooks"; import { countryCodeToTranslatedName } from "~/utils/i18n"; import invariant from "~/utils/invariant"; -import { rawSensToString } from "~/utils/strings"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { FAQ_PAGE } from "~/utils/urls"; import { action } from "../actions/u.$identifier.edit.server"; import { loader } from "../loaders/u.$identifier.edit.server"; import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; -import { COUNTRY_CODES, USER } from "../user-page-constants"; +import { COUNTRY_CODES } from "../user-page-constants"; +import { userEditProfileBaseSchema } from "../user-page-schemas"; export { loader, action }; -import styles from "~/styles/u.$identifier.module.css"; +export const handle: SendouRouteHandle = { + i18n: ["user"], +}; export default function UserEditPage() { const { t } = useTranslation(["common", "user"]); @@ -43,545 +32,141 @@ export default function UserEditPage() { const isSupporter = useHasRole("SUPPORTER"); const isArtist = useHasRole("ARTIST"); + const countryOptions = useCountryOptions(); + + const badgeOptions = data.user.badges.map((badge) => ({ + id: badge.id, + displayName: badge.displayName, + code: badge.code, + hue: badge.hue, + })); + + const defaultValues = { + css: layoutData.css ?? null, + customName: data.user.customName ?? "", + customUrl: layoutData.user.customUrl ?? "", + inGameName: data.user.inGameName ?? "", + sensitivity: sensDefaultValue(data.user.motionSens, data.user.stickSens), + pronouns: pronounsDefaultValue(data.user.pronouns), + battlefy: data.user.battlefy ?? "", + country: data.user.country ?? null, + favoriteBadgeIds: data.favoriteBadgeIds ?? [], + weapons: data.user.weapons.map((w) => ({ + id: w.weaponSplId, + isFavorite: Boolean(w.isFavorite), + })), + bio: data.user.bio ?? "", + showDiscordUniqueName: Boolean(data.user.showDiscordUniqueName), + commissionsOpen: Boolean(layoutData.user.commissionsOpen), + commissionText: layoutData.user.commissionText ?? "", + newProfileEnabled: isSupporter && data.newProfileEnabled, + }; + return (
-
- {isSupporter ? ( - - ) : null} - - - - - - - - - - - {data.discordUniqueName ? ( - - ) : ( - - )} - {isArtist ? ( + + {({ FormField }) => ( <> - - - - ) : ( - <> - - + {isSupporter ? ( + + {(props: CustomFieldRenderProps) => ( + + )} + + ) : null} + + + + + + + + {data.user.badges.length >= 2 ? ( + + ) : null} + + + {data.discordUniqueName ? ( + + ) : null} + {isArtist ? ( + <> + + + + ) : null} + + + + Username, profile picture, YouTube, Bluesky and Twitch accounts + come from your Discord account. See{" "} + FAQ for more information. + + )} - - - - Username, profile picture, YouTube, Bluesky and Twitch accounts come - from your Discord account. See FAQ for - more information. - - - {t("common:actions.save")} - - +
); } -function CustomUrlInput({ - parentRouteData, -}: { - parentRouteData: UserPageLoaderData; -}) { - const { t } = useTranslation(["user"]); - - return ( -
- - - {t("user:forms.info.customUrl")} -
- ); -} - -function CustomNameInput() { - const { t } = useTranslation(["user"]); - const data = useLoaderData(); - - return ( -
- - - - {t("user:forms.customName.info", { - discordName: data.user.discordName, - })} - -
- ); -} - -function InGameNameInputs() { - const { t } = useTranslation(["user"]); - const data = useLoaderData(); - - const inGameNameParts = data.user.inGameName?.split("#"); - - return ( -
- -
- -
#
- -
-
- ); -} - -const SENS_OPTIONS = [ - -50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35, - 40, 45, 50, -]; -function SensSelects() { - const { t } = useTranslation(["user"]); - const data = useLoaderData(); - - return ( -
-
- - -
- -
- - -
-
- ); -} - -function PronounsSelect() { - const { t } = useTranslation(["user"]); - const data = useLoaderData(); - - return ( -
-
-
- - - / -
-
- - -
-
- {t("user:pronounsInfo")} -
- ); -} - -function CountrySelect() { - const { t, i18n } = useTranslation(["user"]); - const data = useLoaderData(); +function useCountryOptions() { + const { i18n } = useTranslation(); const isMounted = useIsMounted(); - const [value, setValue] = React.useState(data.user.country ?? null); - // TODO: if react-aria-components start supporting "suppressHydrationWarning" it would likely be a better solution here - const items = COUNTRY_CODES.map((countryCode) => ({ - name: isMounted + return COUNTRY_CODES.map((countryCode) => ({ + value: countryCode, + label: isMounted ? countryCodeToTranslatedName({ countryCode, language: i18n.language, }) : countryCode, - id: countryCode, - key: countryCode, })).sort((a, b) => - a.name.localeCompare(b.name, i18n.language, { sensitivity: "base" }), - ); - - return ( - <> - {/* TODO: this is a workaround for clearable not working with uncontrolled values, in future the component should handle this one way or another */} - - setValue(value as string | null)} - clearable - > - {({ key, ...item }) => ( - - {item.name} - - )} - - + a.label.localeCompare(b.label, i18n.language, { sensitivity: "base" }), ); } -function BattlefyInput() { - const { t } = useTranslation(["user"]); - const data = useLoaderData(); - - return ( -
- - - {t("user:forms.info.battlefy")} -
- ); -} - -function WeaponPoolSelect() { - const data = useLoaderData(); - const [weapons, setWeapons] = React.useState(data.user.weapons); - const { t } = useTranslation(["user"]); - - const latestWeapon = weapons[weapons.length - 1]; - - return ( -
- - {weapons.length < USER.WEAPON_POOL_MAX_SIZE ? ( - { - setWeapons([ - ...weapons, - { - weaponSplId, - isFavorite: 0, - }, - ]); - }} - disabledWeaponIds={weapons.map((w) => w.weaponSplId)} - // empty on selection - key={latestWeapon?.weaponSplId ?? "empty"} - /> - ) : ( - - {t("user:forms.errors.maxWeapons")} - - )} -
- {weapons.map((weapon) => { - return ( -
-
- -
-
- : } - variant="minimal" - aria-label="Favorite weapon" - onPress={() => - setWeapons( - weapons.map((w) => - w.weaponSplId === weapon.weaponSplId - ? { - ...weapon, - isFavorite: weapon.isFavorite === 1 ? 0 : 1, - } - : w, - ), - ) - } - /> - } - variant="minimal-destructive" - aria-label="Delete weapon" - onPress={() => - setWeapons( - weapons.filter( - (w) => w.weaponSplId !== weapon.weaponSplId, - ), - ) - } - data-testid={`delete-weapon-${weapon.weaponSplId}`} - size="small" - /> -
-
- ); - })} -
-
- ); -} - -function BioTextarea({ - initialValue, -}: { - initialValue: Tables["User"]["bio"]; +function CssCustomField({ + value, + onChange, + initialColors, +}: CustomFieldRenderProps & { + initialColors?: Record | null; }) { - const { t } = useTranslation("user"); - const [value, setValue] = React.useState(initialValue ?? ""); - return ( -
- -