mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-17 05:08:13 +08:00
fix(mobile): name the invalid field when the server rejects a request
A rejected save, such as an Actions rule with an invalid webhook URL, only flashed "Unprocessable content" for 1.5 s. Show the server's message that names the field under it, keep such toasts up for 5 s, and let the iOS toast wrap that line instead of cutting it off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,9 @@ public enum Toast {
|
||||
direction: options.position?.position() ?? .top,
|
||||
dismissBy: [.time(time: TimeInterval(options.duration))]
|
||||
)
|
||||
ToastViewSwift.Toast.default(image: options.type.image(), title: options.title, subtitle: options.message, config: config)
|
||||
// Let the message wrap instead of cutting off a server error that names the field
|
||||
let viewConfig = ToastViewConfiguration(subtitleNumberOfLines: 0)
|
||||
ToastViewSwift.Toast.default(image: options.type.image(), title: options.title, subtitle: options.message, viewConfig: viewConfig, config: config)
|
||||
.show(haptic: options.type.haptic())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ import type { ToastProps } from "./types"
|
||||
|
||||
export const CenteredToast = (props: ToastProps) => {
|
||||
const renderMessage = props.render ? null : props.message ? (
|
||||
<Text className="font-semibold text-white">{props.message}</Text>
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold text-white">{props.message}</Text>
|
||||
{!!props.description && (
|
||||
<Text className="mt-0.5 text-sm text-white/80">{props.description}</Text>
|
||||
)}
|
||||
</View>
|
||||
) : null
|
||||
const { register } = use(ToastActionContext)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface ToastProps {
|
||||
variant: "bottom-stack" | "center-replace"
|
||||
type: "success" | "error" | "info"
|
||||
message: string
|
||||
/** A second line under the message, such as the reason a request failed */
|
||||
description?: string
|
||||
render: React.ReactNode
|
||||
|
||||
action: {
|
||||
@@ -18,7 +20,7 @@ export interface ToastProps {
|
||||
}
|
||||
|
||||
export type CenterToastProps = Partial<
|
||||
Pick<ToastProps, "message" | "render" | "type" | "duration" | "icon">
|
||||
Pick<ToastProps, "message" | "description" | "render" | "type" | "duration" | "icon">
|
||||
> & {
|
||||
variant: "center-replace"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ vi.mock("i18next", () => ({
|
||||
if (key === "errors:2012") {
|
||||
return "RSSHub feed subscription limit exceeded"
|
||||
}
|
||||
if (key === "errors:3") {
|
||||
return "Unprocessable content"
|
||||
}
|
||||
return key.replace(/^errors:/u, "")
|
||||
},
|
||||
}))
|
||||
@@ -78,6 +81,36 @@ describe("toastFetchError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("toastFetchError for a rejected request body", () => {
|
||||
beforeEach(() => {
|
||||
mocks.toastError.mockClear()
|
||||
})
|
||||
|
||||
it("names the field the server rejected under the translated code", () => {
|
||||
toastFetchError(
|
||||
new FollowAPIError(
|
||||
"`rules.0.result.webhooks.0`: Invalid URL\nRequest: PUT /actions (original: /actions)\nArgs: {}",
|
||||
422,
|
||||
"3",
|
||||
),
|
||||
)
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith("Unprocessable content", {
|
||||
description: "`rules.0.result.webhooks.0`: Invalid URL",
|
||||
})
|
||||
})
|
||||
|
||||
it("names the field under a title", () => {
|
||||
toastFetchError(new FollowAPIError("`rules.0.condition`: Invalid input", 422, "3"), {
|
||||
title: "Failed to save",
|
||||
})
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith("Failed to save", {
|
||||
description: "`rules.0.condition`: Invalid input",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getFetchErrorMessage", () => {
|
||||
it("describes a request that never reached the server instead of the native reason", () => {
|
||||
const error = new Error(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FollowAPIError } from "@follow-app/client-sdk"
|
||||
import { ExceptionCodeMap, FollowAPIError } from "@follow-app/client-sdk"
|
||||
import { t } from "i18next"
|
||||
import { FetchError } from "ofetch"
|
||||
|
||||
@@ -80,6 +80,8 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
|
||||
? t("common:app.api_unreachable")
|
||||
: sanitizeErrorMessage(error.message)
|
||||
let message = fallbackMessage
|
||||
// What the server said, kept next to the translation of its error code
|
||||
let serverMessage = ""
|
||||
let _reason = ""
|
||||
let code: number | undefined
|
||||
let status: number | undefined
|
||||
@@ -100,6 +102,7 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
|
||||
code = typeof _code === "number" ? _code : Number(_code)
|
||||
}
|
||||
message = typeof _message === "string" && _message.trim() ? _message : message
|
||||
serverMessage = typeof _message === "string" ? _message : ""
|
||||
if (code != null) {
|
||||
const tValue = t(`errors:${code}` as any)
|
||||
if (tValue !== `${code}`) {
|
||||
@@ -120,6 +123,7 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
|
||||
code = Number(error.code)
|
||||
}
|
||||
status = error.status ? Number(error.status) : status
|
||||
serverMessage = error.message
|
||||
try {
|
||||
if (error.code) {
|
||||
const tValue = t(`errors:${code}` as any)
|
||||
@@ -134,6 +138,7 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
|
||||
}
|
||||
|
||||
message = sanitizeErrorMessage(message)
|
||||
serverMessage = sanitizeErrorMessage(serverMessage)
|
||||
|
||||
// 2fa errors are handled by the form
|
||||
if (code === 4007 || code === 4008) {
|
||||
@@ -156,9 +161,15 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
|
||||
return
|
||||
}
|
||||
|
||||
// Every request validation failure has this code; only the server's message names the field
|
||||
const detail =
|
||||
code === ExceptionCodeMap.UnprocessableContent && serverMessage && serverMessage !== message
|
||||
? serverMessage
|
||||
: ""
|
||||
|
||||
if (!_reason) {
|
||||
const title = _title || message || "Unknown error"
|
||||
return toast.error(title)
|
||||
return detail ? toast.error(title, { description: detail }) : toast.error(title)
|
||||
} else {
|
||||
return toast.error(message || _title || "Unknown error")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ToastProps } from "../components/ui/toast/types"
|
||||
export const toastInstance = new ToastManager()
|
||||
|
||||
type CommandToastOptions = Partial<
|
||||
Pick<ToastProps, "duration" | "icon" | "render" | "message" | "position">
|
||||
Pick<ToastProps, "duration" | "icon" | "render" | "message" | "description" | "position">
|
||||
>
|
||||
type Toast = {
|
||||
// [key in "error" | "success" | "info"]: (message: string) => void;
|
||||
@@ -27,18 +27,24 @@ interface NativeToasterOptions {
|
||||
duration?: number
|
||||
position?: "top" | "center" | "bottom"
|
||||
}
|
||||
// A second line takes longer to read than the default short toast allows
|
||||
const DEFAULT_DURATION = 1500
|
||||
const DESCRIBED_DURATION = 5000
|
||||
|
||||
export const toast = {
|
||||
show: toastInstance.show.bind(toastInstance),
|
||||
} as Toast
|
||||
;(["error", "success", "info"] as const).forEach((type) => {
|
||||
toast[type] = (message: string, options: CommandToastOptions = {}) => {
|
||||
const duration =
|
||||
options.duration ?? (options.description ? DESCRIBED_DURATION : DEFAULT_DURATION)
|
||||
if (Platform.OS === "ios") {
|
||||
const NativeToaster = requireNativeModule("Toaster")
|
||||
NativeToaster.toast({
|
||||
// title: message,
|
||||
message,
|
||||
// The native toast shows the title in bold above the message
|
||||
...(options.description ? { title: message, message: options.description } : { message }),
|
||||
type,
|
||||
duration: options.duration ? options.duration / 1000 : 1.5,
|
||||
duration: duration / 1000,
|
||||
position: options.position,
|
||||
} as NativeToasterOptions)
|
||||
return
|
||||
@@ -48,8 +54,8 @@ export const toast = {
|
||||
type,
|
||||
message,
|
||||
variant: "center-replace",
|
||||
duration: options.duration ?? 1500,
|
||||
...options,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user