diff options
author | Christian Fehmer <[email protected]> | 2024-10-15 14:53:39 +0200 |
---|---|---|
committer | GitHub <[email protected]> | 2024-10-15 14:53:39 +0200 |
commit | 31d1d51d6eafabe5329cab7d44185e8281d3a2cb (patch) | |
tree | 4af5651b6bc3819efe596ea7c413d31b6bb574b2 | |
parent | c7751d90519383ce3f183bc334ab03e7e74bab5d (diff) | |
download | monkeytype-31d1d51d6eafabe5329cab7d44185e8281d3a2cb.tar.gz monkeytype-31d1d51d6eafabe5329cab7d44185e8281d3a2cb.zip |
feat: validate username on name update (@fehmer) (#5961)
-rw-r--r-- | frontend/src/ts/elements/input-indicator.ts | 6 | ||||
-rw-r--r-- | frontend/src/ts/modals/simple-modals.ts | 32 | ||||
-rw-r--r-- | frontend/src/ts/utils/simple-modal.ts | 108 | ||||
-rw-r--r-- | packages/contracts/src/users.ts | 7 |
4 files changed, 126 insertions, 27 deletions
diff --git a/frontend/src/ts/elements/input-indicator.ts b/frontend/src/ts/elements/input-indicator.ts index fa25dc786..6bdca8eb2 100644 --- a/frontend/src/ts/elements/input-indicator.ts +++ b/frontend/src/ts/elements/input-indicator.ts @@ -6,13 +6,13 @@ type InputIndicatorOption = { }; export class InputIndicator { - private inputElement: JQuery; + private inputElement: JQuery | HTMLInputElement; private parentElement: JQuery; private options: Record<string, InputIndicatorOption>; private currentStatus: keyof typeof this.options | null; constructor( - inputElement: JQuery, + inputElement: JQuery | HTMLInputElement, options: Record<string, InputIndicatorOption> ) { this.inputElement = inputElement; @@ -34,7 +34,7 @@ export class InputIndicator { ? `data-balloon-length="large"` : "" } - data-balloon-pos="up" + data-balloon-pos="left" ${option.message ?? "" ? `aria-label="${option.message}"` : ""} > <i class="fas fa-fw ${option.icon} ${ diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index ec4af4a24..3c82afac2 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -35,6 +35,7 @@ import { } from "../utils/simple-modal"; import { ShowOptions } from "../utils/animated-modal"; import { GenerateDataRequest } from "@monkeytype/contracts/dev"; +import { UserNameSchema } from "@monkeytype/contracts/users"; type PopupKey = | "updateEmail" @@ -449,6 +450,18 @@ list.updateName = new SimpleModal({ placeholder: "new name", type: "text", initVal: "", + validation: { + schema: UserNameSchema, + isValid: async (newName: string) => { + const checkNameResponse = ( + await Ape.users.getNameAvailability({ + params: { name: newName }, + }) + ).status; + + return checkNameResponse === 200 ? true : "Name not available"; + }, + }, }, ], buttonText: "update", @@ -462,22 +475,6 @@ list.updateName = new SimpleModal({ }; } - const checkNameResponse = await Ape.users.getNameAvailability({ - params: { name: newName }, - }); - - if (checkNameResponse.status === 409) { - return { - status: 0, - message: "Name not available", - }; - } else if (checkNameResponse.status !== 200) { - return { - status: -1, - message: "Failed to check name: " + checkNameResponse.body.message, - }; - } - const updateNameResponse = await Ape.users.updateName({ body: { name: newName }, }); @@ -1275,6 +1272,9 @@ list.devGenerateData = new SimpleModal({ span.innerHTML = `if checked, user will be created with ${target.value}@example.com and password: password`; return; }, + validation: { + schema: UserNameSchema, + }, }, { type: "checkbox", diff --git a/frontend/src/ts/utils/simple-modal.ts b/frontend/src/ts/utils/simple-modal.ts index 6763627fa..bfcf588f7 100644 --- a/frontend/src/ts/utils/simple-modal.ts +++ b/frontend/src/ts/utils/simple-modal.ts @@ -4,6 +4,8 @@ import { format as dateFormat } from "date-fns/format"; import * as Loader from "../elements/loader"; import * as Notifications from "../elements/notifications"; import * as ConnectionState from "../states/connection"; +import { InputIndicator } from "../elements/input-indicator"; +import { debounce } from "throttle-debounce"; type CommonInput<TType, TValue> = { type: TType; @@ -14,6 +16,25 @@ type CommonInput<TType, TValue> = { optional?: boolean; label?: string; oninput?: (event: Event) => void; + /** + * Validate the input value and indicate the validation result next to the input. + * If the schema is defined it is always checked first. + * Only if the schema validaton is passed or missing the `isValid` method is called. + */ + validation?: { + /** + * Zod schema to validate the input value against. + * The indicator will show the error messages from the schema. + */ + schema?: Zod.Schema<TValue>; + /** + * Custom async validation method. + * This is intended to be used for validations that cannot be handled with a Zod schema like server-side validations. + * @param value current input value + * @returns true if the `value` is valid, an errorMessage as string if it is invalid. + */ + isValid?: (value: string) => Promise<true | string>; + }; }; export type TextInput = CommonInput<"text", string>; @@ -67,6 +88,9 @@ export type ExecReturn = { afterHide?: () => void; }; +type CommonInputTypeWithIndicator = CommonInputType & { + indicator?: InputIndicator; +}; type SimpleModalOptions = { id: string; title: string; @@ -90,7 +114,7 @@ export class SimpleModal { modal: AnimatedModal; id: string; title: string; - inputs: CommonInputType[]; + inputs: CommonInputTypeWithIndicator[]; text?: string; textAllowHtml: boolean; buttonText: string; @@ -286,10 +310,77 @@ export class SimpleModal { } inputs.append(buildTag({ tagname, classes, attributes })); } + const element = document.querySelector( + "#" + attributes["id"] + ) as HTMLInputElement; if (input.oninput !== undefined) { - ( - document.querySelector("#" + attributes["id"]) as HTMLElement - ).oninput = input.oninput; + element.oninput = input.oninput; + } + if (input.validation !== undefined) { + const indicator = new InputIndicator(element, { + valid: { + icon: "fa-check", + level: 1, + }, + invalid: { + icon: "fa-times", + level: -1, + }, + checking: { + icon: "fa-circle-notch", + spinIcon: true, + level: 0, + }, + }); + input.indicator = indicator; + + const debouceIsValid = debounce(1000, async (value: string) => { + const result = await input.validation?.isValid?.(value); + + if (element.value !== value) { + //value of the input has changed in the meantime. discard + return; + } + + if (result === true) { + indicator.show("valid"); + } else { + indicator.show("invalid", result); + } + }); + + const validateInput = async (value: string): Promise<void> => { + if (value === undefined || value === "") { + indicator.hide(); + return; + } + if (input.validation?.schema !== undefined) { + const schemaResult = input.validation.schema.safeParse(value); + if (!schemaResult.success) { + indicator.show( + "invalid", + schemaResult.error.errors.map((err) => err.message).join(", ") + ); + return; + } + } + + if (input.validation?.isValid !== undefined) { + indicator.show("checking"); + void debouceIsValid(value); + return; + } + + indicator.show("valid"); + }; + + element.oninput = async (event) => { + const value = (event.target as HTMLInputElement).value; + await validateInput(value); + + //call original handler if defined + input.oninput?.(event); + }; } }); @@ -307,14 +398,14 @@ export class SimpleModal { } } - type CommonInputWithCurrentValue = CommonInputType & { + type CommonInputWithCurrentValue = CommonInputTypeWithIndicator & { currentValue: string | undefined; }; const inputsWithCurrentValue: CommonInputWithCurrentValue[] = []; for (let i = 0; i < this.inputs.length; i++) { inputsWithCurrentValue.push({ - ...(this.inputs[i] as CommonInputType), + ...(this.inputs[i] as CommonInputTypeWithIndicator), currentValue: vals[i], }); } @@ -328,6 +419,11 @@ export class SimpleModal { return; } + if (inputsWithCurrentValue.some((i) => i.indicator?.get() === "invalid")) { + Notifications.add("Please solve all validation errors", 0); + return; + } + this.disableInputs(); Loader.show(); void this.execFn(this, ...vals).then((res) => { diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts index 8855b81f6..4a835284e 100644 --- a/packages/contracts/src/users.ts +++ b/packages/contracts/src/users.ts @@ -37,13 +37,16 @@ export const GetUserResponseSchema = responseWithData( ); export type GetUserResponse = z.infer<typeof GetUserResponseSchema>; -const UserNameSchema = doesNotContainProfanity( +export const UserNameSchema = doesNotContainProfanity( "substring", z .string() .min(1) .max(16) - .regex(/^[\da-zA-Z_-]+$/) + .regex( + /^[\da-zA-Z_-]+$/, + "Can only contain lower/uppercase letters, underscare and minus." + ) ); export const CreateUserRequestSchema = z.object({ |