aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChristian Fehmer <[email protected]>2024-10-15 14:53:39 +0200
committerGitHub <[email protected]>2024-10-15 14:53:39 +0200
commit31d1d51d6eafabe5329cab7d44185e8281d3a2cb (patch)
tree4af5651b6bc3819efe596ea7c413d31b6bb574b2
parentc7751d90519383ce3f183bc334ab03e7e74bab5d (diff)
downloadmonkeytype-31d1d51d6eafabe5329cab7d44185e8281d3a2cb.tar.gz
monkeytype-31d1d51d6eafabe5329cab7d44185e8281d3a2cb.zip
feat: validate username on name update (@fehmer) (#5961)
-rw-r--r--frontend/src/ts/elements/input-indicator.ts6
-rw-r--r--frontend/src/ts/modals/simple-modals.ts32
-rw-r--r--frontend/src/ts/utils/simple-modal.ts108
-rw-r--r--packages/contracts/src/users.ts7
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({