diff --git a/frontend/src/ts/modals/edit-profile.ts b/frontend/src/ts/modals/edit-profile.ts index 1ca19fc06947..8a4cc1723c89 100644 --- a/frontend/src/ts/modals/edit-profile.ts +++ b/frontend/src/ts/modals/edit-profile.ts @@ -7,7 +7,14 @@ import * as ConnectionState from "../states/connection"; import AnimatedModal from "../utils/animated-modal"; import * as Profile from "../elements/profile"; import { CharacterCounter } from "../elements/character-counter"; -import { Badge, UserProfileDetails } from "@monkeytype/contracts/schemas/users"; +import { + Badge, + GithubProfileSchema, + TwitterProfileSchema, + UserProfileDetails, + WebsiteSchema, +} from "@monkeytype/contracts/schemas/users"; +import { InputIndicator } from "../elements/input-indicator"; export function show(): void { if (!ConnectionState.get()) { @@ -44,6 +51,12 @@ const githubInput = $("#editProfileModal .github"); const websiteInput = $("#editProfileModal .website"); const badgeIdsSelect = $("#editProfileModal .badgeSelectionContainer"); +const indicators = [ + addValidation(twitterInput, TwitterProfileSchema), + addValidation(githubInput, GithubProfileSchema), + addValidation(websiteInput, WebsiteSchema), +]; + let currentSelectedBadgeId = -1; function hydrateInputs(): void { @@ -90,6 +103,8 @@ function hydrateInputs(): void { badgeIdsSelect.find(".badgeSelectionItem").removeClass("selected"); $(currentTarget).addClass("selected"); }); + + indicators.forEach((it) => it.hide()); } function initializeCharacterCounters(): void { @@ -175,6 +190,45 @@ async function updateProfile(): Promise { hide(); } +function addValidation( + element: JQuery, + schema: Zod.Schema +): InputIndicator { + 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, + }, + }); + + element.on("input", (event) => { + const value = (event.target as HTMLInputElement).value; + if (value === undefined || value === "") { + indicator.hide(); + return; + } + const validationResult = schema.safeParse(value); + if (!validationResult.success) { + indicator.show( + "invalid", + validationResult.error.errors.map((err) => err.message).join(", ") + ); + return; + } + indicator.show("valid"); + }); + return indicator; +} + const modal = new AnimatedModal({ dialogId: "editProfileModal", setup: async (modalEl): Promise => { diff --git a/packages/contracts/src/schemas/users.ts b/packages/contracts/src/schemas/users.ts index 2c691d775d9d..d3fa1dc5d4ce 100644 --- a/packages/contracts/src/schemas/users.ts +++ b/packages/contracts/src/schemas/users.ts @@ -87,27 +87,33 @@ function profileDetailsBase( .transform((value) => (value === null ? undefined : value)); } +export const TwitterProfileSchema = profileDetailsBase( + z + .string() + .max(20) + .regex(/^[0-9a-zA-Z_.-]+$/) +).or(z.literal("")); + +export const GithubProfileSchema = profileDetailsBase( + z + .string() + .max(39) + .regex(/^[0-9a-zA-Z_.-]+$/) +).or(z.literal("")); + +export const WebsiteSchema = profileDetailsBase( + z.string().url().max(200).startsWith("https://") +).or(z.literal("")); + export const UserProfileDetailsSchema = z .object({ bio: profileDetailsBase(z.string().max(250)).or(z.literal("")), keyboard: profileDetailsBase(z.string().max(75)).or(z.literal("")), socialProfiles: z .object({ - twitter: profileDetailsBase( - z - .string() - .max(20) - .regex(/^[0-9a-zA-Z_.-]+$/) - ).or(z.literal("")), - github: profileDetailsBase( - z - .string() - .max(39) - .regex(/^[0-9a-zA-Z_.-]+$/) - ).or(z.literal("")), - website: profileDetailsBase( - z.string().url().max(200).startsWith("https://") - ).or(z.literal("")), + twitter: TwitterProfileSchema, + github: GithubProfileSchema, + website: WebsiteSchema, }) .strict() .optional(),