From 0e4b9c4687d5901995e06f163109311d7b3e4824 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 13 Nov 2025 17:12:37 +0100 Subject: [PATCH] impr: handle backend unavailable in remote validations (@fehmer) (#7105) --- frontend/src/ts/elements/input-validation.ts | 4 ++- frontend/src/ts/modals/edit-tag.ts | 3 +- frontend/src/ts/modals/google-sign-up.ts | 16 +++------- frontend/src/ts/modals/simple-modals.ts | 16 +++------- frontend/src/ts/pages/friends.ts | 16 +++------- frontend/src/ts/pages/login.ts | 16 +++------- frontend/src/ts/utils/remote-validation.ts | 32 ++++++++++++++++++++ frontend/src/ts/utils/simple-modal.ts | 6 +++- 8 files changed, 62 insertions(+), 47 deletions(-) create mode 100644 frontend/src/ts/utils/remote-validation.ts diff --git a/frontend/src/ts/elements/input-validation.ts b/frontend/src/ts/elements/input-validation.ts index a1971bd62..0ad5554b2 100644 --- a/frontend/src/ts/elements/input-validation.ts +++ b/frontend/src/ts/elements/input-validation.ts @@ -14,6 +14,8 @@ export type ValidationResult = { errorMessage?: string; }; +export type IsValidResponse = true | string | { warning: string }; + export type Validation = { /** * Zod schema to validate the input value against. @@ -28,7 +30,7 @@ export type Validation = { * @param thisPopup the current modal * @returns true if the `value` is valid, an errorMessage as string if it is invalid. */ - isValid?: (value: T) => Promise; + isValid?: (value: T) => Promise; /** custom debounce delay for `isValid` call. defaults to 100 */ debounceDelay?: number; diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index 701169818..eacd882fd 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -1,12 +1,13 @@ import Ape from "../ape"; import * as DB from "../db"; +import { IsValidResponse } from "../elements/input-validation"; import * as Settings from "../pages/settings"; import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; import { SimpleModal, TextInput } from "../utils/simple-modal"; import { TagNameSchema } from "@monkeytype/schemas/users"; const cleanTagName = (tagName: string): string => tagName.replaceAll(" ", "_"); -const tagNameValidation = async (tagName: string): Promise => { +const tagNameValidation = async (tagName: string): Promise => { const validationResult = TagNameSchema.safeParse(cleanTagName(tagName)); if (validationResult.success) return true; return validationResult.error.errors.map((err) => err.message).join(", "); diff --git a/frontend/src/ts/modals/google-sign-up.ts b/frontend/src/ts/modals/google-sign-up.ts index 278a2e10d..fc452a05e 100644 --- a/frontend/src/ts/modals/google-sign-up.ts +++ b/frontend/src/ts/modals/google-sign-up.ts @@ -16,6 +16,7 @@ import AnimatedModal from "../utils/animated-modal"; import { resetIgnoreAuthCallback } from "../firebase"; import { validateWithIndicator } from "../elements/input-validation"; import { UserNameSchema } from "@monkeytype/schemas/users"; +import { remoteValidation } from "../utils/remote-validation"; let signedInUser: UserCredential | undefined = undefined; @@ -154,17 +155,10 @@ function disableInput(): void { validateWithIndicator(nameInputEl, { schema: UserNameSchema, - isValid: async (name: string) => { - const checkNameResponse = await Ape.users.getNameAvailability({ - params: { name: name }, - }); - - return ( - (checkNameResponse.status === 200 && - checkNameResponse.body.data.available) || - "Name not available" - ); - }, + isValid: remoteValidation( + async (name) => Ape.users.getNameAvailability({ params: { name } }), + { check: (data) => data.available || "Name not available" } + ), debounceDelay: 1000, callback: (result) => { if (result.status === "success") { diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index 019dd2e4c..74b74103e 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -45,6 +45,7 @@ import { import { goToPage } from "../pages/leaderboards"; import FileStorage from "../utils/file-storage"; import { z } from "zod"; +import { remoteValidation } from "../utils/remote-validation"; type PopupKey = | "updateEmail" @@ -479,17 +480,10 @@ list.updateName = new SimpleModal({ initVal: "", validation: { schema: UserNameSchema, - isValid: async (newName: string) => { - const checkNameResponse = await Ape.users.getNameAvailability({ - params: { name: newName }, - }); - - return ( - (checkNameResponse.status === 200 && - checkNameResponse.body.data.available) || - "Name not available" - ); - }, + isValid: remoteValidation( + async (name) => Ape.users.getNameAvailability({ params: { name } }), + { check: (data) => data.available || "Name not available" } + ), debounceDelay: 1000, }, }, diff --git a/frontend/src/ts/pages/friends.ts b/frontend/src/ts/pages/friends.ts index eb1f30aaf..0f7152e13 100644 --- a/frontend/src/ts/pages/friends.ts +++ b/frontend/src/ts/pages/friends.ts @@ -29,6 +29,7 @@ import { Connection } from "@monkeytype/schemas/connections"; import { Friend, UserNameSchema } from "@monkeytype/schemas/users"; import * as Loader from "../elements/loader"; import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; +import { remoteValidation } from "../utils/remote-validation"; const pageElement = $(".page.pageFriends"); @@ -75,17 +76,10 @@ const addFriendModal = new SimpleModal({ initVal: "", validation: { schema: UserNameSchema, - isValid: async (name: string) => { - const checkNameResponse = await Ape.users.getNameAvailability({ - params: { name: name }, - }); - - return ( - (checkNameResponse.status === 200 && - !checkNameResponse.body.data.available) || - "Unknown user" - ); - }, + isValid: remoteValidation( + async (name) => Ape.users.getNameAvailability({ params: { name } }), + { check: (data) => !data.available || "Unknown user" } + ), debounceDelay: 1000, }, }, diff --git a/frontend/src/ts/pages/login.ts b/frontend/src/ts/pages/login.ts index b9a83b2d4..7111ec3fa 100644 --- a/frontend/src/ts/pages/login.ts +++ b/frontend/src/ts/pages/login.ts @@ -10,6 +10,7 @@ import { import { validateWithIndicator } from "../elements/input-validation"; import { isDevEnvironment } from "../utils/misc"; import { z } from "zod"; +import { remoteValidation } from "../utils/remote-validation"; let registerForm: { name?: string; @@ -73,17 +74,10 @@ const nameInputEl = document.querySelector( ) as HTMLInputElement; validateWithIndicator(nameInputEl, { schema: UserNameSchema, - isValid: async (name: string) => { - const checkNameResponse = await Ape.users.getNameAvailability({ - params: { name: name }, - }); - - return ( - (checkNameResponse.status === 200 && - checkNameResponse.body.data.available) || - "Name not available" - ); - }, + isValid: remoteValidation( + async (name) => Ape.users.getNameAvailability({ params: { name } }), + { check: (data) => data.available || "Name not available" } + ), debounceDelay: 1000, callback: (result) => { registerForm.name = diff --git a/frontend/src/ts/utils/remote-validation.ts b/frontend/src/ts/utils/remote-validation.ts new file mode 100644 index 000000000..0b90198fd --- /dev/null +++ b/frontend/src/ts/utils/remote-validation.ts @@ -0,0 +1,32 @@ +import { IsValidResponse } from "../elements/input-validation"; + +type IsValidResonseOrFunction = + | ((message: string) => IsValidResponse) + | IsValidResponse; +export function remoteValidation( + call: ( + val: V + ) => Promise<{ status: number; body: { data?: T; message: string } }>, + options?: { + check?: (data: T) => IsValidResponse; + on4xx?: IsValidResonseOrFunction; + on5xx?: IsValidResonseOrFunction; + } +): (val: V) => Promise { + return async (val) => { + const result = await call(val); + if (result.status <= 299) { + return options?.check?.(result.body.data as T) ?? true; + } + + let handler: IsValidResonseOrFunction | undefined; + if (result.status <= 499) { + handler = options?.on4xx ?? ((message) => message); + } else { + handler = options?.on5xx ?? "Server unavailable. Please try again later."; + } + + if (typeof handler === "function") return handler(result.body.message); + return handler; + }; +} diff --git a/frontend/src/ts/utils/simple-modal.ts b/frontend/src/ts/utils/simple-modal.ts index f5b62be04..a311fee46 100644 --- a/frontend/src/ts/utils/simple-modal.ts +++ b/frontend/src/ts/utils/simple-modal.ts @@ -5,6 +5,7 @@ import * as Loader from "../elements/loader"; import * as Notifications from "../elements/notifications"; import * as ConnectionState from "../states/connection"; import { + IsValidResponse, Validation, ValidationOptions, ValidationResult, @@ -33,7 +34,10 @@ type CommonInput = { * @param thisPopup the current modal * @returns true if the `value` is valid, an errorMessage as string if it is invalid. */ - isValid?: (value: string, thisPopup: SimpleModal) => Promise; + isValid?: ( + value: string, + thisPopup: SimpleModal + ) => Promise; }; };