Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions app/controllers/contacts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,7 @@ def contact

def edit_profile_action
form_values = JSON.parse(params.require(:formValues), symbolize_names: true)
edited_profile_details = form_values[:editedProfileDetails]
edit_profile_reason = form_values[:editProfileReason]
edited_profile_details_from_form = form_values[:editedProfileDetails]
attachment = params[:attachment]
wca_id = form_values[:wcaId]
person = Person.find_by(wca_id: wca_id)
Expand All @@ -114,13 +113,17 @@ def edit_profile_action
gender: person.gender,
dob: person.dob,
}

edited_profile_details = edited_profile_details_from_form.transform_values { |v| v[:newValue] }

changes_requested = Person.fields_edit_requestable
.reject { |field| profile_to_edit[field].to_s == edited_profile_details[field].to_s }
.map do |field|
ContactEditProfile::EditProfileChange.new(
field: field,
from: profile_to_edit[field],
to: edited_profile_details[field],
reason: edited_profile_details_from_form[field][:editReason],
)
end

Expand All @@ -129,7 +132,6 @@ def edit_profile_action
name: profile_to_edit[:name],
wca_id: wca_id,
changes_requested: changes_requested,
edit_profile_reason: edit_profile_reason,
requestor_user: current_user,
document: attachment,
request: request,
Expand Down
2 changes: 1 addition & 1 deletion app/models/contact_edit_profile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
class ContactEditProfile < ContactForm
attribute :wca_id
attribute :changes_requested
attribute :edit_profile_reason
attribute :requestor_user
attribute :ticket
attribute :document, attachment: true
Expand All @@ -12,6 +11,7 @@ class ContactEditProfile < ContactForm
:field,
:from,
:to,
:reason,
)

def value_humanized(value, field)
Expand Down
8 changes: 7 additions & 1 deletion app/views/mail_form/contact_edit_profile.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@
<td><%= change[:field].to_s.humanize %></td>
<td><%= @resource.value_humanized(change[:from], change[:field]) %> -> <%= @resource.value_humanized(change[:to], change[:field]) %></td>
</tr>
<% if change[:reason].present? %>
<tr>
<td colspan="2" style="font-size: 0.9em; color: #666; padding-bottom: 10px;">
Reason: <%= change[:reason] %>
</td>
</tr>
<% end %>
<% end %>
<tr style="height: 1em">
<td colspan="2"></td>
</tr>
</body>
</table>
<p>Edit Reason: <%= @resource.edit_profile_reason %></p>
<p>Requestor: <%= @resource.requestor_info %></p>
<p>You can edit this person using <%= link_to "this ticket (##{@resource.ticket.id})", ticket_url(@resource.ticket.id) %>.</p>
<% if @resource.document.present? %>
Expand Down
135 changes: 59 additions & 76 deletions app/webpacker/components/ContactEditProfilePage/EditProfileForm.jsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,78 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useMemo, useState, useCallback } from 'react';
import { Form, Message } from 'semantic-ui-react';
import ReCAPTCHA from 'react-google-recaptcha';
import { QueryClient, useQuery } from '@tanstack/react-query';
import _ from 'lodash';
import I18n from '../../lib/i18n';
import { apiV0Urls, contactEditProfileActionUrl } from '../../lib/requests/routes.js.erb';
import { contactEditProfileActionUrl } from '../../lib/requests/routes.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';
import RegionSelector from '../wca/RegionSelector';
import GenderSelector from '../wca/GenderSelector';
import EditNameField from './fields/EditNameField';
import EditRegionField from './fields/EditRegionField';
import EditGenderField from './fields/EditGenderField';
import EditDobField from './fields/EditDobField';

const CONTACT_EDIT_PROFILE_FORM_QUERY_CLIENT = new QueryClient();
const EDITABLE_FIELDS = [
{ name: 'name', Component: EditNameField },
{ name: 'country_iso2', Component: EditRegionField },
{ name: 'gender', Component: EditGenderField },
{ name: 'dob', Component: EditDobField },
];

export default function EditProfileForm({
wcaId,
profileDetails,
onContactSuccess,
recaptchaPublicKey,
}) {
const [editProfileReason, setEditProfileReason] = useState();
const [editedProfileDetails, setEditedProfileDetails] = useState();
const [editedProfileDetails, setEditedProfileDetails] = useState(() => _.fromPairs(
EDITABLE_FIELDS.map(({ name }) => [
name,
{ newValue: profileDetails?.[name] || '', editReason: '' },
]),
));
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 hasFieldBeenChanged = useCallback((field) => !_.isEqual(
profileDetails?.[field],
editedProfileDetails[field].newValue,
), [profileDetails, editedProfileDetails]);

const profileDetails = data?.data?.person;
const isSubmitDisabled = useMemo(() => {
if (!profileDetails || !captchaValue) return true;

const isSubmitDisabled = useMemo(
() => !editedProfileDetails || _.isEqual(editedProfileDetails, profileDetails) || !captchaValue,
[captchaValue, editedProfileDetails, profileDetails],
);
const changedFields = Object.keys(editedProfileDetails).filter(hasFieldBeenChanged);

const noChanges = changedFields.length === 0;
const hasMissingReason = changedFields.some(
(field) => !editedProfileDetails[field].editReason.trim(),
);

useEffect(() => {
setEditedProfileDetails(profileDetails);
}, [profileDetails]);
return noChanges || hasMissingReason;
}, [captchaValue, editedProfileDetails, hasFieldBeenChanged, profileDetails]);

const handleValueChange = (_event, { name, value }) => {
setEditedProfileDetails((prev) => ({
...prev,
[name]: { ...prev[name], newValue: value },
}));
};

const handleEditReasonChange = (_event, { name, value }) => {
setEditedProfileDetails((prev) => ({
...prev,
[name]: { ...prev[name], editReason: value },
}));
};

const formSubmitHandler = () => {
const formData = new FormData();

formData.append('formValues', JSON.stringify({
editedProfileDetails, editProfileReason, wcaId,
editedProfileDetails, wcaId,
}));
if (proofAttachment) {
formData.append('attachment', proofAttachment);
Expand All @@ -63,25 +87,11 @@ export default function EditProfileForm({
);
};

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 <Loading />;
if (isError) return <Errored />;
if (saving) return <Loading />;

return (
<Form onSubmit={formSubmitHandler} error={!!saveError} warning>
Expand All @@ -91,43 +101,16 @@ export default function EditProfileForm({
content={saveError.json?.error || 'Something went wrong.'}
/>
)}
<Form.Input
label={I18n.t('activerecord.attributes.user.name')}
name="name"
value={editedProfileDetails?.name}
onChange={handleFormChange}
required
/>
<RegionSelector
label={I18n.t('activerecord.attributes.user.country_iso2')}
name="country_iso2"
onlyCountries
region={editedProfileDetails?.country_iso2}
onRegionChange={handleFormChange}
/>
<GenderSelector
name="gender"
gender={editedProfileDetails?.gender}
onChange={handleFormChange}
/>
<Form.Field
label={I18n.t('activerecord.attributes.user.dob')}
name="dob"
control={UtcDatePicker}
showYearDropdown
dateFormatOverride="yyyy-MM-dd"
dropdownMode="select"
isoDate={editedProfileDetails?.dob}
onChange={handleDobChange}
required
/>
<Form.TextArea
label={I18n.t('page.contact_edit_profile.form.edit_reason.label')}
name="editProfileReason"
required
value={editProfileReason}
onChange={handleEditProfileReasonChange}
/>
{EDITABLE_FIELDS.map(({ name, Component }) => (
<Component
key={name}
value={editedProfileDetails[name].newValue}
reason={editedProfileDetails[name].editReason}
isChanged={hasFieldBeenChanged(name)}
onValueChange={handleValueChange}
onReasonChange={handleEditReasonChange}
/>
))}
<Form.Input
label={I18n.t('page.contact_edit_profile.form.proof_attach.label')}
type="file"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiV0Urls } from '../../lib/requests/routes.js.erb';
import { fetchJsonOrError } from '../../lib/requests/fetchWithAuthenticityToken';
import Loading from '../Requests/Loading';
import Errored from '../Requests/Errored';
import EditProfileForm from './EditProfileForm';

export default function EditProfileFormHolder({
wcaId,
onContactSuccess,
recaptchaPublicKey,
}) {
const { data, isLoading, isError } = useQuery({
queryKey: ['profileData', wcaId],
queryFn: () => fetchJsonOrError(apiV0Urls.persons.show(wcaId)),
});

if (isLoading) return <Loading />;
if (isError) return <Errored />;

const profileDetails = data?.data?.person;

return (
<EditProfileForm
wcaId={wcaId}
profileDetails={profileDetails}
onContactSuccess={onContactSuccess}
recaptchaPublicKey={recaptchaPublicKey}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { Form } from 'semantic-ui-react';
import I18n from '../../../lib/i18n';
import UtcDatePicker from '../../wca/UtcDatePicker';
import EditReasonField from './EditReasonField';

export default function EditDobField({
value, reason, isChanged, onValueChange, onReasonChange,
}) {
return (
<>
<Form.Field
label={I18n.t('activerecord.attributes.user.dob')}
name="dob"
control={UtcDatePicker}
showYearDropdown
dateFormatOverride="yyyy-MM-dd"
dropdownMode="select"
isoDate={value}
onChange={(date) => onValueChange(null, { name: 'dob', value: date })}
required
/>
<EditReasonField
name="dob"
label={I18n.t('activerecord.attributes.user.dob')}
isChanged={isChanged}
value={reason}
onChange={onReasonChange}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import GenderSelector from '../../wca/GenderSelector';
import EditReasonField from './EditReasonField';
import I18n from '../../../lib/i18n';

export default function EditGenderField({
value, reason, isChanged, onValueChange, onReasonChange,
}) {
return (
<>
<GenderSelector
name="gender"
gender={value}
onChange={onValueChange}
/>
<EditReasonField
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about not requiring a reason for gender change?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a really good point, but I like to take it up in a follow-up PR in a different way. This PR aims to have text field for all the reasons, and the improvements for each of them will be a separate problem to solve.

For DOB, instead of not making reason mandatory, I prefer giving user the option to tick a checkbox which says that they agree that this change must be done, [...]. This way we will get an extra validation from user that they are finen with this change.

Copy link
Copy Markdown
Member

@gregorbg gregorbg May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately what Maks pointed out is a politically sensitive topic. Requiring this change -- even though it makes sense "for now" in your code architecture -- may still put pressure on transgender individuals who want to submit a profile change five minutes after this PR was deployed. We do not want that pressure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I would like to inform that this is even the current behavior - so the pressure already exists. I was just trying to keep the existing behavior as it is.

Here are the things I can do here:

  1. Leave it as it is, because the existing behavior is also same in contact edit profile form.
  2. A middle ground solution - for non-gender fields I'll use the generic EditReasonField, and for gender I'll have a radio button options where first option (default) will be "I prefer not to answer", and second option will be "I would like to give an explanation". If user clicks second option, then they will have the 'edit reason' field which will now become mandatory.

@gregorbg Which option do you prefer here?

name="gender"
label={I18n.t('activerecord.attributes.user.gender')}
isChanged={isChanged}
value={reason}
onChange={onReasonChange}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import { Form } from 'semantic-ui-react';
import I18n from '../../../lib/i18n';
import EditReasonField from './EditReasonField';

export default function EditNameField({
value, reason, isChanged, onValueChange, onReasonChange,
}) {
return (
<>
<Form.Input
label={I18n.t('activerecord.attributes.user.name')}
name="name"
value={value || ''}
onChange={onValueChange}
required
/>
<EditReasonField
name="name"
label={I18n.t('activerecord.attributes.user.name')}
isChanged={isChanged}
value={reason}
onChange={onReasonChange}
/>
</>
);
}
Loading