mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-16 20:51:43 +08:00
fix(desktop): put the server's profile errors on their fields
The profile form toasted the server's English text for a taken handle, a blank name or a value too long, and its own limits differed from the server's (a 50-character name, 64-character website). Update-user codes now show their translated message under the field, the form checks the server's lengths, and the handle lookup stays for servers without them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import enCommon from "@locales/common/en.json"
|
||||
import enSettings from "@locales/settings/en.json"
|
||||
import i18next from "i18next"
|
||||
import { beforeAll, describe, expect, test } from "vitest"
|
||||
|
||||
import { getProfileFieldError, profileFormSchema } from "./profile-form-schema"
|
||||
|
||||
const profile = { handle: "folo", name: "Folo", image: "", bio: "", website: "" }
|
||||
|
||||
const errorOf = (values: Partial<typeof profile>) => {
|
||||
const result = profileFormSchema.safeParse({ ...profile, ...values })
|
||||
return result.success ? undefined : result.error.issues[0]!.message
|
||||
}
|
||||
|
||||
describe("profileFormSchema", () => {
|
||||
beforeAll(async () => {
|
||||
await i18next.init({
|
||||
lng: "en",
|
||||
resources: { en: { common: enCommon, settings: enSettings } },
|
||||
})
|
||||
})
|
||||
|
||||
test("accepts what the server's columns hold", () => {
|
||||
expect(errorOf({ name: "n".repeat(64) })).toBeUndefined()
|
||||
expect(errorOf({ handle: "h".repeat(36) })).toBeUndefined()
|
||||
expect(errorOf({ website: `https://example.com/${"a".repeat(200)}` })).toBeUndefined()
|
||||
// The server counts an emoji as one character
|
||||
expect(errorOf({ name: "😀".repeat(64) })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("refuses what the server would refuse, with the limit", () => {
|
||||
expect(errorOf({ name: "n".repeat(65) })).toBe("Use at most 64 characters.")
|
||||
expect(errorOf({ handle: "h".repeat(37) })).toBe("Use at most 36 characters.")
|
||||
expect(errorOf({ bio: "b".repeat(257) })).toBe("Use at most 256 characters.")
|
||||
expect(errorOf({ website: `https://example.com/${"a".repeat(240)}` })).toBe(
|
||||
"Use at most 256 characters.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getProfileFieldError", () => {
|
||||
test("puts the server's update-user codes on their fields", () => {
|
||||
expect(getProfileFieldError("HANDLE_ALREADY_TAKEN")).toEqual({
|
||||
field: "handle",
|
||||
message: "This handle is already taken.",
|
||||
})
|
||||
expect(getProfileFieldError("NAME_CANNOT_BE_EMPTY")).toEqual({
|
||||
field: "name",
|
||||
message: "This field is required.",
|
||||
})
|
||||
expect(getProfileFieldError("WEBSITE_TOO_LONG")).toEqual({
|
||||
field: "website",
|
||||
message: "Use at most 256 characters.",
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves other errors to the generic message", () => {
|
||||
expect(getProfileFieldError("VALIDATION_ERROR")).toBeUndefined()
|
||||
expect(getProfileFieldError("toString")).toBeUndefined()
|
||||
expect(getProfileFieldError(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import i18next from "i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
export const socialLinksSchema = z.object({
|
||||
twitter: z.string().max(32).optional(),
|
||||
github: z.string().max(32).optional(),
|
||||
instagram: z.string().max(32).optional(),
|
||||
facebook: z.string().max(32).optional(),
|
||||
youtube: z.string().max(32).optional(),
|
||||
discord: z.string().max(32).optional(),
|
||||
})
|
||||
|
||||
// The sizes of the user table's columns, which the server checks before it saves a profile
|
||||
const MAX_LENGTH = {
|
||||
handle: 36,
|
||||
name: 64,
|
||||
image: 256,
|
||||
bio: 256,
|
||||
website: 256,
|
||||
} as const
|
||||
|
||||
export type ProfileTextField = keyof typeof MAX_LENGTH
|
||||
|
||||
const tooLongMessage = (field: ProfileTextField) =>
|
||||
i18next.t("validation.too_long", { ns: "common", count: MAX_LENGTH[field] })
|
||||
|
||||
// Counted as the server counts them, so an emoji is one character and not two UTF-16 units
|
||||
const withMaxLength = (schema: z.ZodString, field: ProfileTextField) =>
|
||||
schema.refine((value) => [...value].length <= MAX_LENGTH[field], {
|
||||
error: () => tooLongMessage(field),
|
||||
})
|
||||
|
||||
export const profileFormSchema = z.object({
|
||||
handle: withMaxLength(
|
||||
z
|
||||
.string()
|
||||
.regex(/^\w+$/, { error: () => i18next.t("profile.handle.invalid", { ns: "settings" }) }),
|
||||
"handle",
|
||||
)
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
name: withMaxLength(z.string().trim().min(1), "name"),
|
||||
image: withMaxLength(z.string().url(), "image").or(z.literal("")).optional(),
|
||||
bio: withMaxLength(z.string(), "bio").optional(),
|
||||
website: withMaxLength(z.string().url(), "website").optional().or(z.literal("")),
|
||||
socialLinks: socialLinksSchema.optional(),
|
||||
})
|
||||
|
||||
// The codes the server's update-user answers a field it refuses with
|
||||
const SERVER_FIELD_ERRORS: Record<string, { field: ProfileTextField; message: () => string }> = {
|
||||
HANDLE_ALREADY_TAKEN: {
|
||||
field: "handle",
|
||||
message: () => i18next.t("profile.handle.taken", { ns: "settings" }),
|
||||
},
|
||||
NAME_CANNOT_BE_EMPTY: {
|
||||
field: "name",
|
||||
message: () => i18next.t("validation.required", { ns: "common" }),
|
||||
},
|
||||
HANDLE_TOO_LONG: { field: "handle", message: () => tooLongMessage("handle") },
|
||||
NAME_TOO_LONG: { field: "name", message: () => tooLongMessage("name") },
|
||||
IMAGE_TOO_LONG: { field: "image", message: () => tooLongMessage("image") },
|
||||
BIO_TOO_LONG: { field: "bio", message: () => tooLongMessage("bio") },
|
||||
WEBSITE_TOO_LONG: { field: "website", message: () => tooLongMessage("website") },
|
||||
}
|
||||
|
||||
/** The form field and message for an update-user error code that names a refused field */
|
||||
export const getProfileFieldError = (code: string | undefined) => {
|
||||
const fieldError = code && Object.hasOwn(SERVER_FIELD_ERRORS, code) && SERVER_FIELD_ERRORS[code]
|
||||
return fieldError ? { field: fieldError.field, message: fieldError.message() } : undefined
|
||||
}
|
||||
@@ -15,11 +15,10 @@ import { userActions } from "@follow/store/user/store"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import i18next from "i18next"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
import type { z } from "zod"
|
||||
|
||||
import { AvatarUploadModal } from "~/components/ui/crop/AvatarUploadModal"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
@@ -28,28 +27,8 @@ import { updateUser } from "~/lib/auth"
|
||||
import { uploadAvatarBlob } from "~/lib/avatar-upload"
|
||||
import { toastFetchError } from "~/lib/error-parser"
|
||||
|
||||
const socialLinksSchema = z.object({
|
||||
twitter: z.string().max(32).optional(),
|
||||
github: z.string().max(32).optional(),
|
||||
instagram: z.string().max(32).optional(),
|
||||
facebook: z.string().max(32).optional(),
|
||||
youtube: z.string().max(32).optional(),
|
||||
discord: z.string().max(32).optional(),
|
||||
})
|
||||
|
||||
const formSchema = z.object({
|
||||
handle: z
|
||||
.string()
|
||||
.max(32)
|
||||
.regex(/^\w+$/, { error: () => i18next.t("profile.handle.invalid", { ns: "settings" }) })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
name: z.string().trim().min(1).max(50),
|
||||
image: z.string().url().or(z.literal("")).optional(),
|
||||
bio: z.string().max(256).optional(),
|
||||
website: z.string().url().max(64).optional().or(z.literal("")),
|
||||
socialLinks: socialLinksSchema.optional(),
|
||||
})
|
||||
import type { ProfileTextField, socialLinksSchema } from "./profile-form-schema"
|
||||
import { getProfileFieldError, profileFormSchema } from "./profile-form-schema"
|
||||
|
||||
const socialIconClassNames = {
|
||||
twitter: "i-mgc-twitter-cute-fi",
|
||||
@@ -62,7 +41,14 @@ const socialIconClassNames = {
|
||||
|
||||
const formItemLabelClassName = tw`pl-3`
|
||||
|
||||
class HandleTakenError extends Error {}
|
||||
class ProfileFieldError extends Error {
|
||||
constructor(
|
||||
readonly field: ProfileTextField,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
const isHandleTakenByOthers = async (handle: string, userId?: string) => {
|
||||
try {
|
||||
@@ -100,8 +86,8 @@ export const ProfileSettingForm = ({
|
||||
const user = useWhoami() as ExtendedUser
|
||||
const { present } = useModalStack()
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
const form = useForm<z.infer<typeof profileFormSchema>>({
|
||||
resolver: zodResolver(profileFormSchema),
|
||||
defaultValues: {
|
||||
handle: user?.handle || "",
|
||||
name: user?.name || "",
|
||||
@@ -120,7 +106,7 @@ export const ProfileSettingForm = ({
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (values: Partial<z.infer<typeof formSchema>>) => {
|
||||
mutationFn: async (values: Partial<z.infer<typeof profileFormSchema>>) => {
|
||||
const res = await updateUser({
|
||||
handle: values.handle,
|
||||
image: values.image,
|
||||
@@ -131,21 +117,26 @@ export const ProfileSettingForm = ({
|
||||
})
|
||||
// The auth client resolves with the error instead of throwing
|
||||
if (res.error) {
|
||||
// The server answers a handle another user has with a bare 500, so look it up
|
||||
const fieldError = getProfileFieldError(res.error.code)
|
||||
if (fieldError) {
|
||||
throw new ProfileFieldError(fieldError.field, fieldError.message)
|
||||
}
|
||||
// Servers without HANDLE_ALREADY_TAKEN answer a handle another user has with a bare 500,
|
||||
// so look it up
|
||||
if (
|
||||
values.handle &&
|
||||
values.handle.toLowerCase() !== user?.handle?.toLowerCase() &&
|
||||
(await isHandleTakenByOthers(values.handle, user?.id))
|
||||
) {
|
||||
throw new HandleTakenError()
|
||||
throw new ProfileFieldError("handle", t("profile.handle.taken"))
|
||||
}
|
||||
throw new Error(res.error.message || res.error.statusText)
|
||||
}
|
||||
return res
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof HandleTakenError) {
|
||||
form.setError("handle", { message: t("profile.handle.taken") })
|
||||
if (error instanceof ProfileFieldError) {
|
||||
form.setError(error.field, { message: error.message })
|
||||
return
|
||||
}
|
||||
toastFetchError(error)
|
||||
@@ -162,7 +153,7 @@ export const ProfileSettingForm = ({
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
function onSubmit(values: z.infer<typeof profileFormSchema>) {
|
||||
// An empty handle field leaves the handle as it is
|
||||
const { handle, ...rest } = values
|
||||
updateMutation.mutate(handle ? values : rest)
|
||||
|
||||
Reference in New Issue
Block a user