From 4c00e1ce1dd751891bdfb9b11018a78ff42eb0cd Mon Sep 17 00:00:00 2001 From: Daniel James Date: Mon, 18 Nov 2024 19:32:37 +0530 Subject: [PATCH] Edit Profile Form (#9811) * Edit Profile Form * Review changes * Fixed unit test issue * Removed old section of edit profile form * Review changes --- app/controllers/contacts_controller.rb | 40 ++++- app/models/contact_edit_profile.rb | 26 +++ app/models/person.rb | 4 + app/views/contacts/edit_profile.html.erb | 5 + app/views/mail_form/contact_edit_profile.erb | 21 +++ .../EditProfileForm.jsx | 163 ++++++++++++++++++ .../ContactEditProfilePage/index.jsx | 58 +++++++ .../components/ContactsPage/ContactForm.jsx | 69 +++++--- .../SubForms/Wrt/EditProfileQuery.jsx | 128 -------------- .../ContactsPage/SubForms/Wrt/index.jsx | 10 +- app/webpacker/lib/requests/routes.js.erb | 2 + config/locales/en.yml | 19 ++ config/routes.rb | 8 +- 13 files changed, 390 insertions(+), 163 deletions(-) create mode 100644 app/models/contact_edit_profile.rb create mode 100644 app/views/contacts/edit_profile.html.erb create mode 100644 app/views/mail_form/contact_edit_profile.erb create mode 100644 app/webpacker/components/ContactEditProfilePage/EditProfileForm.jsx create mode 100644 app/webpacker/components/ContactEditProfilePage/index.jsx delete mode 100644 app/webpacker/components/ContactsPage/SubForms/Wrt/EditProfileQuery.jsx diff --git a/app/controllers/contacts_controller.rb b/app/controllers/contacts_controller.rb index f7f593c16c..d950a896cd 100644 --- a/app/controllers/contacts_controller.rb +++ b/app/controllers/contacts_controller.rb @@ -37,7 +37,7 @@ class ContactsController < ApplicationController end private def new_profile_data_key_to_value(new_profile_data, profile_data_to_change) - if profile_data_to_change == 'country' + if profile_data_to_change == 'country_iso2' Country.find_by(iso2: new_profile_data).name else new_profile_data @@ -99,6 +99,44 @@ def contact end end + def edit_profile_action + formValues = JSON.parse(params.require(:formValues), symbolize_names: true) + edited_profile_details = formValues[:editedProfileDetails] + edit_profile_reason = formValues[:editProfileReason] + attachment = params[:attachment] + wca_id = formValues[:wcaId] + + return render status: :unauthorized, json: { error: "Cannot request profile change without login" } unless current_user.present? + + profile_to_edit = { + name: current_user.person.name, + country_iso2: current_user.person.country_iso2, + gender: current_user.person.gender, + dob: current_user.person.dob, + } + changes_requested = Person.fields_edit_requestable + .reject { |field| profile_to_edit[field].to_s == edited_profile_details[field].to_s } + .map { |field| + ContactEditProfile::EditProfileChange.new( + field: field.to_s.humanize, + from: (new_profile_data_key_to_value(profile_to_edit[field], field.to_s) || "Unknown").to_s, + to: new_profile_data_key_to_value(edited_profile_details[field], field.to_s), + ) + } + + maybe_send_contact_email( + ContactEditProfile.new( + your_email: current_user&.email, + name: profile_to_edit[:name], + wca_id: wca_id, + changes_requested: changes_requested, + edit_profile_reason: edit_profile_reason, + document: attachment, + request: request, + ), + ) + end + def dob @contact = DobContact.new(your_email: current_user&.email) end diff --git a/app/models/contact_edit_profile.rb b/app/models/contact_edit_profile.rb new file mode 100644 index 0000000000..7570e68cb6 --- /dev/null +++ b/app/models/contact_edit_profile.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ContactEditProfile < ContactForm + attribute :wca_id + attribute :changes_requested + attribute :edit_profile_reason + attribute :document, attachment: true + + EditProfileChange = Struct.new( + :field, + :from, + :to, + ) + + def to_email + UserGroup.teams_committees_group_wrt.metadata.email + end + + def subject + Time.now.strftime("Edit Profile request by #{wca_id} on %d %b %Y at %R") + end + + def headers + super.merge(template_name: "contact_edit_profile") + end +end diff --git a/app/models/person.rb b/app/models/person.rb index babb089d19..819e930da4 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -269,6 +269,10 @@ def url Rails.application.routes.url_helpers.person_url(wca_id, host: EnvConfig.ROOT_URL) end + def self.fields_edit_requestable + [:name, :country_iso2, :gender, :dob].freeze + end + DEFAULT_SERIALIZE_OPTIONS = { only: ["wca_id", "name", "gender"], methods: ["url", "country_iso2"], diff --git a/app/views/contacts/edit_profile.html.erb b/app/views/contacts/edit_profile.html.erb new file mode 100644 index 0000000000..5c54d2b47d --- /dev/null +++ b/app/views/contacts/edit_profile.html.erb @@ -0,0 +1,5 @@ +<% provide(:title, t('page.contact_edit_profile.title')) %> +<%= react_component("ContactEditProfilePage", { + loggedInUserId: current_user&.id, + recaptchaPublicKey: AppSecrets.RECAPTCHA_PUBLIC_KEY, +}) %> diff --git a/app/views/mail_form/contact_edit_profile.erb b/app/views/mail_form/contact_edit_profile.erb new file mode 100644 index 0000000000..1ca8340d40 --- /dev/null +++ b/app/views/mail_form/contact_edit_profile.erb @@ -0,0 +1,21 @@ +

Hey WRT,

+

+ <%= @resource.wca_id %> requested following change in their profile: +

+ + + <% @resource.changes_requested.each do |change| %> + + + + + <% end %> + + + + +
<%= change[:field] %><%= change[:from] %> -> <%= change[:to] %>
+

You can edit this person <%= link_to "here", panel_index_url(panel_id: 'wrt', wcaId: @resource.wca_id, anchor: User.panel_pages[:editPerson]) %>.

+<% if @resource.document.present? %> +

Note: There is a proof attachment to this email.

+<% end %> diff --git a/app/webpacker/components/ContactEditProfilePage/EditProfileForm.jsx b/app/webpacker/components/ContactEditProfilePage/EditProfileForm.jsx new file mode 100644 index 0000000000..a927dbe2b5 --- /dev/null +++ b/app/webpacker/components/ContactEditProfilePage/EditProfileForm.jsx @@ -0,0 +1,163 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Form, Message } from 'semantic-ui-react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import { QueryClient, useQuery } from '@tanstack/react-query'; +import i18n from '../../lib/i18n'; +import { apiV0Urls, contactEditProfileActionUrl } from '../../lib/requests/routes.js.erb'; +import { genders, countries } from '../../lib/wca-data.js.erb'; +import Loading from '../Requests/Loading'; +import Errored from '../Requests/Errored'; +import useSaveAction from '../../lib/hooks/useSaveAction'; +import { fetchJsonOrError } from '../../lib/requests/fetchWithAuthenticityToken'; +import UtcDatePicker from '../wca/UtcDatePicker'; + +const CONTACT_EDIT_PROFILE_FORM_QUERY_CLIENT = new QueryClient(); + +const genderOptions = _.map(genders.byId, (gender) => ({ + key: gender.id, + text: gender.name, + value: gender.id, +})); + +const countryOptions = _.map(countries.byIso2, (country) => ({ + key: country.iso2, + text: country.name, + value: country.iso2, +})); + +export default function EditProfileForm({ + wcaId, + onContactSuccess, + recaptchaPublicKey, +}) { + const [editProfileReason, setEditProfileReason] = useState(); + const [editedProfileDetails, setEditedProfileDetails] = useState(); + const [proofAttachment, setProofAttachment] = useState(); + const [captchaValue, setCaptchaValue] = useState(); + const [captchaError, setCaptchaError] = useState(false); + const [saveError, setSaveError] = useState(); + const { save, saving } = useSaveAction(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['profileData'], + queryFn: () => fetchJsonOrError(apiV0Urls.persons.show(wcaId)), + }, CONTACT_EDIT_PROFILE_FORM_QUERY_CLIENT); + + const profileDetails = data?.data?.person; + + const isSubmitDisabled = useMemo( + () => !editedProfileDetails || _.isEqual(editedProfileDetails, profileDetails) || !captchaValue, + [captchaValue, editedProfileDetails, profileDetails], + ); + + useEffect(() => { + setEditedProfileDetails(profileDetails); + }, [profileDetails]); + + const formSubmitHandler = () => { + const formData = new FormData(); + + formData.append('formValues', JSON.stringify({ + editedProfileDetails, editProfileReason, wcaId, + })); + formData.append('attachment', proofAttachment); + + save( + contactEditProfileActionUrl, + formData, + onContactSuccess, + { method: 'POST', headers: {}, body: formData }, + setSaveError, + ); + }; + + const handleEditProfileReasonChange = (e, { value }) => { + setEditProfileReason(value); + }; + + const handleProofUpload = (event) => { + setProofAttachment(event.target.files[0]); + }; + + const handleFormChange = (e, { name: formName, value }) => { + setEditedProfileDetails((prev) => ({ ...prev, [formName]: value })); + }; + + const handleDobChange = (date) => handleFormChange(null, { + name: 'dob', + value: date, + }); + + if (saving || isLoading) return ; + if (saveError || isError) return ; + + return ( +
+ + + + + + + + + {captchaError && ( + + )} + + + {i18n.t('page.contact_edit_profile.form.submit_edit_request_button.label')} + + + ); +} diff --git a/app/webpacker/components/ContactEditProfilePage/index.jsx b/app/webpacker/components/ContactEditProfilePage/index.jsx new file mode 100644 index 0000000000..e75130288b --- /dev/null +++ b/app/webpacker/components/ContactEditProfilePage/index.jsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { Container, Header, Message } from 'semantic-ui-react'; +import { QueryClient, useQuery } from '@tanstack/react-query'; +import i18n from '../../lib/i18n'; +import I18nHTMLTranslate from '../I18nHTMLTranslate'; +import { apiV0Urls } from '../../lib/requests/routes.js.erb'; +import Loading from '../Requests/Loading'; +import { fetchJsonOrError } from '../../lib/requests/fetchWithAuthenticityToken'; +import Errored from '../Requests/Errored'; +import EditProfileForm from './EditProfileForm'; + +const CONTACT_EDIT_PROFILE_QUERY_CLIENT = new QueryClient(); + +export default function ContactEditProfilePage({ loggedInUserId, recaptchaPublicKey }) { + const { data: loggedInUserData, isLoading, isError } = useQuery({ + queryKey: ['userData'], + queryFn: () => fetchJsonOrError(apiV0Urls.users.me.userDetails), + enabled: !!loggedInUserId, + }, CONTACT_EDIT_PROFILE_QUERY_CLIENT); + const wcaId = loggedInUserData?.data?.user?.wca_id; + const [contactSuccess, setContactSuccess] = useState(false); + + if (isLoading) return ; + if (isError) return ; + if (!loggedInUserData) { + return ( + + + + ); + } + if (loggedInUserData && !wcaId) { + return ( + + + + ); + } + if (contactSuccess) { + return ( + + ); + } + + return ( + +
{i18n.t('page.contact_edit_profile.title')}
+ setContactSuccess(true)} + recaptchaPublicKey={recaptchaPublicKey} + /> +
+ ); +} diff --git a/app/webpacker/components/ContactsPage/ContactForm.jsx b/app/webpacker/components/ContactsPage/ContactForm.jsx index fbd14076a8..04d9e030ad 100644 --- a/app/webpacker/components/ContactsPage/ContactForm.jsx +++ b/app/webpacker/components/ContactsPage/ContactForm.jsx @@ -4,7 +4,7 @@ import { } from 'semantic-ui-react'; import ReCAPTCHA from 'react-google-recaptcha'; import _ from 'lodash'; -import { contactUrl } from '../../lib/requests/routes.js.erb'; +import { contactUrl, contactEditProfileActionUrl } from '../../lib/requests/routes.js.erb'; import useSaveAction from '../../lib/hooks/useSaveAction'; import I18n from '../../lib/i18n'; import UserData from './UserData'; @@ -26,6 +26,15 @@ const CONTACT_RECIPIENTS = [ const CONTACT_RECIPIENTS_MAP = _.keyBy(CONTACT_RECIPIENTS, _.camelCase); +const getFormRedirection = (formValues) => { + if (formValues.contactRecipient === CONTACT_RECIPIENTS_MAP.wrt) { + if (formValues[CONTACT_RECIPIENTS_MAP.wrt].queryType === 'edit_profile') { + return contactEditProfileActionUrl; + } + } + return null; +} + export default function ContactForm({ loggedInUserData, recaptchaPublicKey, @@ -38,9 +47,10 @@ export default function ContactForm({ const contactFormState = useStore(); const dispatch = useDispatch(); const { formValues: { contactRecipient: selectedContactRecipient, userData } } = contactFormState; + const formRedirection = useMemo(() => getFormRedirection(contactFormState.formValues)); const isFormValid = ( - selectedContactRecipient && userData.name && userData.email && captchaValue + selectedContactRecipient && userData.name && userData.email && (captchaValue || formRedirection) ); const contactSuccessHandler = () => { @@ -116,28 +126,39 @@ export default function ContactForm({ ))} {SubForm && } - - - {captchaError && ( - - )} - - + {formRedirection ? ( + + ) : ( + <> + + + {captchaError && ( + + )} + + + + )} ); diff --git a/app/webpacker/components/ContactsPage/SubForms/Wrt/EditProfileQuery.jsx b/app/webpacker/components/ContactsPage/SubForms/Wrt/EditProfileQuery.jsx deleted file mode 100644 index d75a4daffb..0000000000 --- a/app/webpacker/components/ContactsPage/SubForms/Wrt/EditProfileQuery.jsx +++ /dev/null @@ -1,128 +0,0 @@ -import React from 'react'; -import { - Form, FormField, FormGroup, Radio, -} from 'semantic-ui-react'; -import { useDispatch, useStore } from '../../../../lib/providers/StoreProvider'; -import { updateSectionData, clearForm, uploadProfileChangeProof } from '../../store/actions'; -import I18n from '../../../../lib/i18n'; -import UtcDatePicker from '../../../wca/UtcDatePicker'; -import { genders, countries } from '../../../../lib/wca-data.js.erb'; - -const SECTION = 'wrt'; -const PROFILE_DATA_FIELDS = ['name', 'country', 'gender', 'dob']; - -const genderOptions = _.map(genders.byId, (gender) => ({ - key: gender.id, - text: gender.name, - value: gender.id, -})); - -const countryOptions = _.map(countries.byIso2, (country) => ({ - key: country.iso2, - text: country.name, - value: country.iso2, -})); - -export default function EditProfileQuery() { - const { - formValues: { - userData: { name: userName, email: userEmail }, - contactRecipient, - wrt: { profileDataToChange, newProfileData, editProfileReason }, - }, - } = useStore(); - const dispatch = useDispatch(); - const handleFormChange = (_, { name, value }) => dispatch( - updateSectionData(SECTION, name, value), - ); - - const handleProfileDataFieldChange = (_, { value }) => dispatch( - clearForm({ - userName, - userEmail, - contactRecipient, - queryType: 'edit_profile', - profileDataToChange: value, - }), - ); - - const handleFileUpload = (event) => dispatch( - uploadProfileChangeProof(event.target.files[0]), - ); - - return ( - <> - -
{I18n.t('page.contacts.form.wrt.profile_data_to_change.label')}
- {PROFILE_DATA_FIELDS.map((profileDataField) => ( - - - - ))} -
- {profileDataToChange && ( - <> - {profileDataToChange === 'name' && ( - - )} - {profileDataToChange === 'country' && ( - - )} - {profileDataToChange === 'gender' && ( - - )} - {profileDataToChange === 'dob' && ( - handleFormChange(null, { - name: 'newProfileData', - value: date, - })} - /> - )} - - - - )} - - ); -} diff --git a/app/webpacker/components/ContactsPage/SubForms/Wrt/index.jsx b/app/webpacker/components/ContactsPage/SubForms/Wrt/index.jsx index 3ac32cd9d4..94a59c51a1 100644 --- a/app/webpacker/components/ContactsPage/SubForms/Wrt/index.jsx +++ b/app/webpacker/components/ContactsPage/SubForms/Wrt/index.jsx @@ -5,7 +5,6 @@ import { import I18n from '../../../../lib/i18n'; import { useDispatch, useStore } from '../../../../lib/providers/StoreProvider'; import { updateSectionData } from '../../store/actions'; -import EditProfileQuery from './EditProfileQuery'; import OtherQuery from './OtherQuery'; const SECTION = 'wrt'; @@ -21,13 +20,8 @@ export default function Wrt() { ); const QueryForm = useMemo(() => { - if (!selectedQueryType) return null; - switch (selectedQueryType) { - case QUERY_TYPES_MAP.editProfile: - return EditProfileQuery; - default: - return OtherQuery; - } + if (!selectedQueryType || selectedQueryType === QUERY_TYPES_MAP.editProfile) return null; + return OtherQuery; }, [selectedQueryType]); return ( diff --git a/app/webpacker/lib/requests/routes.js.erb b/app/webpacker/lib/requests/routes.js.erb index 4e1e3b5f5d..2b1dfb194d 100644 --- a/app/webpacker/lib/requests/routes.js.erb +++ b/app/webpacker/lib/requests/routes.js.erb @@ -179,6 +179,8 @@ export const adminAvatarsUrl = `<%= CGI.unescape(Rails.application.routes.url_he export const contactUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.contact_path) %>` +export const contactEditProfileActionUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.contact_edit_profile_action_path) %>` + export const contactRecipientUrl = (contactRecipient) => `<%= CGI.unescape(Rails.application.routes.url_helpers.contact_path(contactRecipient: "${contactRecipient}")) %>` export const apiV0Urls = { diff --git a/config/locales/en.yml b/config/locales/en.yml index edf10661d2..47b4be031d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -869,7 +869,26 @@ en: label: "Message" captcha: validation_error: "Please verify that you are not a robot." + next_button: "Next" submit_button: "Submit" + contact_edit_profile: + title: "Edit Profile Form" + not_logged_in_error: "Looks like you are not logged in. Please log in to edit your profile. For any other queries, please contact us." + no_profile_error: "Looks like you don't have a WCA profile yet because you have not participated in any competitions. For any other queries, please contact us." + form: + contact_email: + label: "Contact Email" + wca_id: + label: "Enter WCA ID" + edit_reason: + label: "Reason for the change" + proof_attach: + label: "Attach proof" + submit_edit_request_button: + label: "Submit Edit Request" + captcha: + validation_error: "Please verify that you are not a robot." + success_message: "Request submitted - we'll get back to you soon." #context: Key used on the website homepage homepage: #context: the "Learn more" button diff --git a/config/routes.rb b/config/routes.rb index febba88287..4fd794107f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -235,8 +235,12 @@ get 'contact' => 'contacts#index' post 'contact' => 'contacts#contact' - get 'contact/dob' => 'contacts#dob' - post 'contact/dob' => 'contacts#dob_create' + scope 'contact' do + get 'edit_profile' => 'contacts#edit_profile' + post 'edit_profile' => 'contacts#edit_profile_action', as: :contact_edit_profile_action + get 'dob' => 'contacts#dob', as: :contact_dob + post 'dob' => 'contacts#dob_create' + end get '/regulations' => 'regulations#show', id: 'index' get '/regulations/wca-regulations-and-guidelines', to: redirect('https://regulations.worldcubeassociation.org/wca-regulations-and-guidelines.pdf', status: 302)