diff --git a/frontend/src/styles/inputs.scss b/frontend/src/styles/inputs.scss index 2e3533bde952..62797722f312 100644 --- a/frontend/src/styles/inputs.scss +++ b/frontend/src/styles/inputs.scss @@ -114,6 +114,7 @@ input[type="checkbox"] { position: relative; transition: background 0.125s; flex-shrink: 0; + cursor: pointer; &:after { font-family: "Font Awesome"; content: "\f00c"; diff --git a/frontend/src/ts/modals/edit-profile.ts b/frontend/src/ts/modals/edit-profile.ts index fcc31ccafefe..09d9fe8d9a6e 100644 --- a/frontend/src/ts/modals/edit-profile.ts +++ b/frontend/src/ts/modals/edit-profile.ts @@ -23,6 +23,8 @@ export function show(): void { void modal.show({ beforeAnimation: async () => { hydrateInputs(); + originalState = getProfileState(); + updateSaveButtonState(); initializeCharacterCounters(); }, }); @@ -32,6 +34,10 @@ function hide(): void { void modal.hide(); } +const saveButton = qsr( + "#editProfileModal .edit-profile-submit", +); + const bioInput = qsr("#editProfileModal .bio"); const keyboardInput = qsr("#editProfileModal .keyboard"); const twitterInput = qsr("#editProfileModal .twitter"); @@ -42,10 +48,41 @@ const showActivityOnPublicProfileInput = qsr( "#editProfileModal .editProfileShowActivityOnPublicProfile", ); +bioInput.on("input", () => { + updateSaveButtonState(); +}); +keyboardInput.on("input", () => { + updateSaveButtonState(); +}); +twitterInput.on("input", () => { + updateSaveButtonState(); +}); +githubInput.on("input", () => { + updateSaveButtonState(); +}); +websiteInput.on("input", () => { + updateSaveButtonState(); +}); +showActivityOnPublicProfileInput.on("change", () => { + updateSaveButtonState(); +}); + const indicators = [ - addValidation(twitterInput, TwitterProfileSchema), - addValidation(githubInput, GithubProfileSchema), - addValidation(websiteInput, WebsiteSchema), + addValidation( + twitterInput, + TwitterProfileSchema, + () => originalState?.twitter ?? "", + ), + addValidation( + githubInput, + GithubProfileSchema, + () => originalState?.github ?? "", + ), + addValidation( + websiteInput, + WebsiteSchema, + () => originalState?.website ?? "", + ), ]; let currentSelectedBadgeId = -1; @@ -100,67 +137,104 @@ function hydrateInputs(): void { badgeIdsSelect?.qsa(".badgeSelectionItem")?.removeClass("selected"); (currentTarget as HTMLElement).classList.add("selected"); + updateSaveButtonState(); }); indicators.forEach((it) => it.hide()); } +let characterCountersInitialized = false; + function initializeCharacterCounters(): void { + if (characterCountersInitialized) return; new CharacterCounter(bioInput, 250); new CharacterCounter(keyboardInput, 75); + characterCountersInitialized = true; +} + +type ProfileState = { + bio: string; + keyboard: string; + twitter: string; + github: string; + website: string; + badgeId: number; + showActivityOnPublicProfile: boolean; +}; + +function getProfileState(): ProfileState { + return { + bio: bioInput.getValue() ?? "", + keyboard: keyboardInput.getValue() ?? "", + twitter: twitterInput.getValue() ?? "", + github: githubInput.getValue() ?? "", + website: websiteInput.getValue() ?? "", + badgeId: currentSelectedBadgeId, + showActivityOnPublicProfile: + showActivityOnPublicProfileInput.isChecked() ?? false, + }; } -function buildUpdatesFromInputs(): UserProfileDetails { - const bio = bioInput.getValue() ?? ""; - const keyboard = keyboardInput.getValue() ?? ""; - const twitter = twitterInput.getValue() ?? ""; - const github = githubInput.getValue() ?? ""; - const website = websiteInput.getValue() ?? ""; - const showActivityOnPublicProfile = - showActivityOnPublicProfileInput.isChecked() ?? false; - - const profileUpdates: UserProfileDetails = { - bio, - keyboard, +function buildUpdatesFromState(state: ProfileState): UserProfileDetails { + return { + bio: state.bio, + keyboard: state.keyboard, socialProfiles: { - twitter, - github, - website, + twitter: state.twitter, + github: state.github, + website: state.website, }, - showActivityOnPublicProfile, + showActivityOnPublicProfile: state.showActivityOnPublicProfile, }; +} + +let originalState: ProfileState | null = null; + +function hasProfileChanged( + originalState: ProfileState | null, + currentState: ProfileState, +): boolean { + if (originalState === null) return true; + + return ( + originalState.bio !== currentState.bio || + originalState.keyboard !== currentState.keyboard || + originalState.twitter !== currentState.twitter || + originalState.github !== currentState.github || + originalState.website !== currentState.website || + originalState.badgeId !== currentState.badgeId || + originalState.showActivityOnPublicProfile !== + currentState.showActivityOnPublicProfile + ); +} + +function updateSaveButtonState(): void { + const currentState = getProfileState(); + const hasChanges = hasProfileChanged(originalState, currentState); - return profileUpdates; + const hasValidationErrors = [ + { value: currentState.twitter, schema: TwitterProfileSchema }, + { value: currentState.github, schema: GithubProfileSchema }, + { value: currentState.website, schema: WebsiteSchema }, + ].some( + ({ value, schema }) => value !== "" && !schema.safeParse(value).success, + ); + + saveButton.native.disabled = !hasChanges || hasValidationErrors; } async function updateProfile(): Promise { const snapshot = DB.getSnapshot(); if (!snapshot) return; - const updates = buildUpdatesFromInputs(); - - // check for length resctrictions before sending server requests - const githubLengthLimit = 39; - if ( - updates.socialProfiles?.github !== undefined && - updates.socialProfiles?.github.length > githubLengthLimit - ) { - showErrorNotification( - `GitHub username exceeds maximum allowed length (${githubLengthLimit} characters).`, - ); - return; - } - const twitterLengthLimit = 20; - if ( - updates.socialProfiles?.twitter !== undefined && - updates.socialProfiles?.twitter.length > twitterLengthLimit - ) { - showErrorNotification( - `Twitter username exceeds maximum allowed length (${twitterLengthLimit} characters).`, - ); + const currentState = getProfileState(); + + if (!hasProfileChanged(originalState, currentState)) { + updateSaveButtonState(); return; } + const updates = buildUpdatesFromState(currentState); showLoaderBar(); const response = await Ape.users.updateProfile({ body: { @@ -185,7 +259,7 @@ async function updateProfile(): Promise { }); DB.setSnapshot(snapshot); - + originalState = currentState; showSuccessNotification("Profile updated"); hide(); @@ -194,6 +268,7 @@ async function updateProfile(): Promise { function addValidation( element: ElementWithUtils, schema: Zod.Schema, + getOriginalValue: () => string, ): InputIndicator { const indicator = new InputIndicator(element, { valid: { @@ -213,10 +288,11 @@ function addValidation( element.on("input", (event) => { const value = (event.target as HTMLInputElement).value; - if (value === undefined || value === "") { + if (value === undefined || value === "" || value === getOriginalValue()) { indicator.hide(); return; } + const validationResult = schema.safeParse(value); if (!validationResult.success) { indicator.show(