From 28d80a7c54b790ff1939b2f273d92be6a0a4eb41 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah Date: Thu, 15 Feb 2024 18:03:32 +0500 Subject: [PATCH] feat: add first and last name to settings page --- src/account-settings/AccountSettingsPage.jsx | 89 ++++- .../AccountSettingsPage.messages.jsx | 10 + src/account-settings/NameField.jsx | 361 ++++++++++++++++++ src/account-settings/data/actions.js | 4 +- src/account-settings/data/sagas.js | 18 +- src/account-settings/data/selectors.js | 10 + .../name-change/NameChange.jsx | 7 +- .../name-change/data/actions.js | 6 +- .../name-change/data/sagas.js | 2 +- .../name-change/data/service.js | 10 +- .../name-change/test/NameChange.test.jsx | 39 ++ .../test/AccountSettingsPage.test.jsx | 101 +++++ 12 files changed, 633 insertions(+), 24 deletions(-) create mode 100644 src/account-settings/NameField.jsx diff --git a/src/account-settings/AccountSettingsPage.jsx b/src/account-settings/AccountSettingsPage.jsx index 8a6e54d05..a391c461d 100644 --- a/src/account-settings/AccountSettingsPage.jsx +++ b/src/account-settings/AccountSettingsPage.jsx @@ -52,6 +52,7 @@ import { fetchSiteLanguages } from './site-language'; import DemographicsSection from './demographics/DemographicsSection'; import { fetchCourseList } from '../notification-preferences/data/thunks'; import { withLocation, withNavigate } from './hoc'; +import NameField from './NameField'; class AccountSettingsPage extends React.Component { constructor(props, context) { @@ -167,6 +168,34 @@ class AccountSettingsPage extends React.Component { this.props.saveSettings(formId, values, extendedProfileObject); }; + handleSubmitFirstAndLastName = (formId, fullName, firstName, lastName) => { + const settingsToBeSaved = []; + + if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) { + settingsToBeSaved.push({ + formId: 'useVerifiedNameForCerts', + commitValues: this.props.formValues.useVerifiedNameForCerts, + }); + } + + settingsToBeSaved.push({ + formId: 'first_name', + commitValues: firstName, + }); + + settingsToBeSaved.push({ + formId: 'last_name', + commitValues: lastName, + }); + + settingsToBeSaved.push({ + formId: 'name', + commitValues: fullName, + }); + + this.props.saveMultipleSettings(settingsToBeSaved, formId, false); + }; + handleSubmitProfileName = (formId, values) => { if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) { this.props.saveMultipleSettings([ @@ -552,37 +581,71 @@ class AccountSettingsPage extends React.Component { isEditable={false} {...editableFieldProps} /> - + ) : ( + + onChange={this.handleEditableFieldChange} + onSubmit={this.handleSubmitProfileName} + /> + )} + {verifiedName && ( { + const { + name, + label, + emptyLabel, + type, + fullNameValue, + firstNameValue, + lastNameValue, + verifiedName, + pendingNameChange, + userSuppliedValue, + saveState, + error, + firstNameError, + lastNameError, + confirmationMessageDefinition, + confirmationValue, + helpText, + onEdit, + onCancel, + onSubmit, + onChange, + isEditing, + isEditable, + isGrayedOut, + intl, + ...others + } = props; + + const id = `field-${name}`; + + const firstNameFieldAttributes = { + name: 'first_name', + id: 'field-firstName', + label: intl.formatMessage(messages['account.settings.field.first.name']), + }; + + const lastNameFieldAttributes = { + name: 'last_name', + id: 'field-lastName', + label: intl.formatMessage(messages['account.settings.field.last.name']), + }; + + const [fullName, setFullName] = useState(''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [fieldError, setFieldError] = useState(''); + + /** + * Concatenates first and last name and generates full name. + * @param first + * @param last + * @returns {`${string} ${string}`} + */ + const generateFullName = (first, last) => { + if (first && last) { + return `${first} ${last}`; + } + return first || last; + }; + + /** + * Splits a full name into first name and last name such that the first word + * is the firstName and rest of the name is last name. + * - If the full name is "John Doe Hamilton", the splitting will be + * e.g., fullName = John Doe => firstName = John, lastName = Doe Hamilton + * @param {string} nameValue The full name to split. + * @returns {object} An object containing the firstName and lastName. + */ + const splitFullName = (nameValue) => { + if (!nameValue) { return null; } + const [first, ...lastNameArr] = nameValue.trim().split(' '); + const last = lastNameArr.join(' '); + return { first, last }; + }; + + /** + * UseEffect for setting first and last name. + */ + useEffect(() => { + if (firstNameValue || lastNameValue) { + setFirstName(firstNameValue); + setLastName(lastNameValue); + } else { + const { first, last } = splitFullName(fullNameValue); + setFirstName(first); + setLastName(last); + } + }, [firstNameValue, fullNameValue, lastNameValue]); + + /** + * UseEffect for setting full name. + */ + useEffect(() => { + if (verifiedName?.status === 'submitted' && pendingNameChange) { + setFullName(pendingNameChange); + } else if (firstNameValue || lastNameValue) { + setFullName(generateFullName(firstNameValue, lastNameValue)); + } else { + setFullName(fullNameValue); + } + }, [firstNameValue, fullNameValue, lastNameValue, pendingNameChange, verifiedName?.status]); + + /** + * UseEffect for setting error + */ + useEffect(() => { + setFieldError(error || firstNameError || lastNameError); + }, [error, firstNameError, lastNameError]); + + const handleSubmit = (e) => { + e.preventDefault(); + const formData = new FormData(e.target); + const firstNameVal = formData.get(firstNameFieldAttributes.name).trim(); + const lastNameVal = formData.get(lastNameFieldAttributes.name).trim(); + const fullNameVal = generateFullName(firstName, lastName); + + onSubmit(name, fullNameVal, firstNameVal, lastNameVal); + }; + + const handleChange = (e, fieldName) => { + onChange(fieldName, e.target.value); + // Updating full name along with the updates to first and last name + if (fieldName === firstNameFieldAttributes.name) { + onChange(name, generateFullName(e.target.value.trim(), lastNameValue)); + } else if (fieldName === lastNameFieldAttributes.name) { + onChange(name, generateFullName(firstNameValue, e.target.value.trim())); + } + }; + + const handleEdit = () => { + onEdit(name); + }; + + const handleCancel = () => { + onCancel(name); + }; + + const renderEmptyLabel = () => { + if (isEditable) { + return ; + } + return {emptyLabel}; + }; + + const renderValue = (rawValue) => { + if (!rawValue) { + return renderEmptyLabel(); + } + let finalValue = rawValue; + + if (userSuppliedValue) { + finalValue += `: ${userSuppliedValue}`; + } + + return finalValue; + }; + + const renderConfirmationMessage = () => { + if (!confirmationMessageDefinition || !confirmationValue) { + return null; + } + return intl.formatMessage(confirmationMessageDefinition, { + value: confirmationValue, + }); + }; + + return ( + +
+ + + + {firstNameFieldAttributes.label} + + { handleChange(e, firstNameFieldAttributes.name); }} + {...others} + /> + + + + {lastNameFieldAttributes.label} + + { handleChange(e, lastNameFieldAttributes.name); }} + {...others} + /> + + {!!helpText && {helpText}} + {fieldError != null && {fieldError}} + {others.children} + +

+ { + // Swallow clicks if the state is pending. + // We do this instead of disabling the button to prevent + // it from losing focus (disabled elements cannot have focus). + // Disabling it would causes upstream issues in focus management. + // Swallowing the onSubmit event on the form would be better, but + // we would have to add that logic for every field given our + // current structure of the application. + if (saveState === 'pending') { e.preventDefault(); } + }} + disabledStates={[]} + /> + +

+
+ {['name', 'verified_name'].includes(name) && } + + ), + default: ( +
+
+
{label}
+ {isEditable ? ( + + ) : null} +
+

+ {renderValue(fullName)} +

+

{renderConfirmationMessage() || helpText}

+
+ ), + }} + /> + ); +}; + +NameField.propTypes = { + name: PropTypes.string.isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]), + emptyLabel: PropTypes.node, + type: PropTypes.string.isRequired, + fullNameValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + firstNameValue: PropTypes.string, + lastNameValue: PropTypes.string, + pendingNameChange: PropTypes.string, + verifiedName: PropTypes.shape({ + verified_name: PropTypes.string, + status: PropTypes.string, + proctored_exam_attempt_id: PropTypes.number, + }), + userSuppliedValue: PropTypes.string, + saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']), + error: PropTypes.string, + firstNameError: PropTypes.string, + lastNameError: PropTypes.string, + confirmationMessageDefinition: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + description: PropTypes.string, + }), + confirmationValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + helpText: PropTypes.node, + onEdit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + isEditing: PropTypes.bool, + isEditable: PropTypes.bool, + isGrayedOut: PropTypes.bool, + intl: intlShape.isRequired, +}; + +NameField.defaultProps = { + fullNameValue: undefined, + firstNameValue: undefined, + lastNameValue: undefined, + pendingNameChange: null, + verifiedName: null, + saveState: undefined, + label: undefined, + emptyLabel: undefined, + error: null, + firstNameError: null, + lastNameError: null, + confirmationMessageDefinition: undefined, + confirmationValue: undefined, + helpText: undefined, + isEditing: false, + isEditable: true, + isGrayedOut: false, + userSuppliedValue: undefined, +}; + +export default connect(nameFieldSelector, { + onEdit: openForm, + onCancel: closeForm, +})(injectIntl(NameField)); diff --git a/src/account-settings/data/actions.js b/src/account-settings/data/actions.js index 27d8f7682..0f80510bf 100644 --- a/src/account-settings/data/actions.js +++ b/src/account-settings/data/actions.js @@ -105,9 +105,9 @@ export const savePreviousSiteLanguage = previousSiteLanguage => ({ payload: { previousSiteLanguage }, }); -export const saveMultipleSettings = (settingsArray, form = null) => ({ +export const saveMultipleSettings = (settingsArray, form = null, saveInSeparateCalls = true) => ({ type: SAVE_MULTIPLE_SETTINGS.BASE, - payload: { settingsArray, form }, + payload: { settingsArray, form, saveInSeparateCalls }, }); export const saveMultipleSettingsBegin = () => ({ diff --git a/src/account-settings/data/sagas.js b/src/account-settings/data/sagas.js index c27cbe9ef..9eb9c13e8 100644 --- a/src/account-settings/data/sagas.js +++ b/src/account-settings/data/sagas.js @@ -124,14 +124,24 @@ export function* handleSaveMultipleSettings(action) { try { yield put(saveMultipleSettingsBegin()); const { username, userId } = getAuthenticatedUser(); - const { settingsArray, form } = action.payload; - for (let i = 0; i < settingsArray.length; i += 1) { - const { formId, commitValues } = settingsArray[i]; + const { settingsArray, form, saveInSeparateCalls } = action.payload; + if (saveInSeparateCalls) { + for (let i = 0; i < settingsArray.length; i += 1) { + const { formId, commitValues } = settingsArray[i]; + yield put(saveSettingsBegin()); + const commitData = { [formId]: commitValues }; + const savedSettings = yield call(patchSettings, username, commitData, userId); + yield put(saveSettingsSuccess(savedSettings, commitData)); + } + } else { + const commitData = settingsArray.reduce((data, setting) => ( + { ...data, [setting.formId]: setting.commitValues } + ), {}); yield put(saveSettingsBegin()); - const commitData = { [formId]: commitValues }; const savedSettings = yield call(patchSettings, username, commitData, userId); yield put(saveSettingsSuccess(savedSettings, commitData)); } + yield put(saveMultipleSettingsSuccess(action)); if (form) { yield delay(1000); diff --git a/src/account-settings/data/selectors.js b/src/account-settings/data/selectors.js index 7650aa01e..d683bfa1a 100644 --- a/src/account-settings/data/selectors.js +++ b/src/account-settings/data/selectors.js @@ -128,6 +128,16 @@ export const editableFieldSelector = createStructuredSelector({ isEditing: isEditingSelector, }); +export const nameFieldSelector = createSelector( + editableFieldSelector, + accountSettingsSelector, + (editableFieldSettings, accountSettings) => ({ + ...editableFieldSettings, + firstNameError: accountSettings.errors?.first_name, + lastNameError: accountSettings.errors?.last_name, + }), +); + export const profileDataManagerSelector = createSelector( accountSettingsSelector, accountSettings => accountSettings.profileDataManager, diff --git a/src/account-settings/name-change/NameChange.jsx b/src/account-settings/name-change/NameChange.jsx index d08a840c5..10d56a9fe 100644 --- a/src/account-settings/name-change/NameChange.jsx +++ b/src/account-settings/name-change/NameChange.jsx @@ -62,7 +62,10 @@ const NameChangeModal = ({ })); } else { const draftProfileName = targetFormId === 'name' ? formValues.name : null; - dispatch(requestNameChange(username, draftProfileName, verifiedNameInput)); + const draftFirstName = targetFormId === 'name' ? formValues?.first_name : null; + const draftLastName = targetFormId === 'name' ? formValues?.last_name : null; + + dispatch(requestNameChange(username, draftProfileName, verifiedNameInput, draftFirstName, draftLastName)); } }; @@ -190,6 +193,8 @@ NameChangeModal.propTypes = { errors: PropTypes.shape({}).isRequired, formValues: PropTypes.shape({ name: PropTypes.string, + first_name: PropTypes.string, + last_name: PropTypes.string, verified_name: PropTypes.string, }).isRequired, saveState: PropTypes.string, diff --git a/src/account-settings/name-change/data/actions.js b/src/account-settings/name-change/data/actions.js index f80fbbbf5..efd0a0935 100644 --- a/src/account-settings/name-change/data/actions.js +++ b/src/account-settings/name-change/data/actions.js @@ -2,9 +2,11 @@ import { AsyncActionType } from '../../data/utils'; export const REQUEST_NAME_CHANGE = new AsyncActionType('ACCOUNT_SETTINGS', 'REQUEST_NAME_CHANGE'); -export const requestNameChange = (username, profileName, verifiedName) => ({ +export const requestNameChange = (username, profileName, verifiedName, firstName, lastName) => ({ type: REQUEST_NAME_CHANGE.BASE, - payload: { username, profileName, verifiedName }, + payload: { + username, profileName, verifiedName, firstName, lastName, + }, }); export const requestNameChangeBegin = () => ({ diff --git a/src/account-settings/name-change/data/sagas.js b/src/account-settings/name-change/data/sagas.js index dfbae8e87..4971a8807 100644 --- a/src/account-settings/name-change/data/sagas.js +++ b/src/account-settings/name-change/data/sagas.js @@ -17,7 +17,7 @@ export function* handleRequestNameChange(action) { try { yield put(requestNameChangeBegin()); if (action.payload.profileName) { - yield call(postNameChange, action.payload.profileName); + yield call(postNameChange, action.payload.profileName, action.payload.firstName, action.payload.lastName); profileName = action.payload.profileName; } yield call(postVerifiedName, { diff --git a/src/account-settings/name-change/data/service.js b/src/account-settings/name-change/data/service.js index 70a6cfc15..565850589 100644 --- a/src/account-settings/name-change/data/service.js +++ b/src/account-settings/name-change/data/service.js @@ -4,13 +4,19 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { handleRequestError } from '../../data/utils'; // eslint-disable-next-line import/prefer-default-export -export async function postNameChange(name) { +export async function postNameChange(name, firstName, lastName) { // Requests a pending name change, rather than saving the account name immediately const requestConfig = { headers: { Accept: 'application/json' } }; const requestUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/name_change/`; + const nameChangePayload = { name }; + if (firstName && lastName) { + nameChangePayload.first_name = firstName; + nameChangePayload.last_name = lastName; + } + const { data } = await getAuthenticatedHttpClient() - .post(requestUrl, { name }, requestConfig) + .post(requestUrl, nameChangePayload, requestConfig) .catch(error => handleRequestError(error)); return data; diff --git a/src/account-settings/name-change/test/NameChange.test.jsx b/src/account-settings/name-change/test/NameChange.test.jsx index 1085f54ea..524111282 100644 --- a/src/account-settings/name-change/test/NameChange.test.jsx +++ b/src/account-settings/name-change/test/NameChange.test.jsx @@ -99,6 +99,8 @@ describe('NameChange', () => { const dispatchData = { payload: { profileName: null, + firstName: null, + lastName: null, username: 'edx', verifiedName: 'Verified Name', }, @@ -167,4 +169,41 @@ describe('NameChange', () => { render(reduxWrapper()); expect(window.location.pathname).toEqual('/id-verification'); }); + + it( + 'dispatches profileName with first and last name if first_name and last_name are available in settings', + async () => { + const dispatchData = { + payload: { + profileName: 'edx edx', + username: 'edx', + verifiedName: 'Verified Name', + firstName: 'first', + lastName: 'last', + }, + type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE', + }; + const formProps = { + ...props, + targetFormId: 'name', + formValues: { + ...props.formValues, + first_name: 'first', + last_name: 'last', + }, + }; + + render(reduxWrapper()); + + const continueButton = screen.getByText('Continue'); + fireEvent.click(continueButton); + + const input = screen.getByPlaceholderText('Enter the name on your photo ID'); + fireEvent.change(input, { target: { value: 'Verified Name' } }); + + const submitButton = screen.getByText('Continue'); + fireEvent.click(submitButton); + expect(mockDispatch).toHaveBeenCalledWith(dispatchData); + }, + ); }); diff --git a/src/account-settings/test/AccountSettingsPage.test.jsx b/src/account-settings/test/AccountSettingsPage.test.jsx index 5acd4c563..d5f175d3c 100644 --- a/src/account-settings/test/AccountSettingsPage.test.jsx +++ b/src/account-settings/test/AccountSettingsPage.test.jsx @@ -11,6 +11,7 @@ import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; import AccountSettingsPage from '../AccountSettingsPage'; import mockData from './mockData'; +import { saveMultipleSettings, saveSettings } from '../data/actions'; const mockDispatch = jest.fn(); jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -98,4 +99,104 @@ describe('AccountSettingsPage', () => { fireEvent.click(submitButton); }); + + it( + 'renders NameField for full name if first_name and last_name are available in account settings', + async () => { + const { getByText, rerender, getByLabelText } = render(reduxWrapper()); + + const fullNameText = getByText('Full name'); + const fullNameEditButton = fullNameText.parentElement.querySelector('button'); + + expect(fullNameEditButton).toBeInTheDocument(); + + store = mockStore({ + ...mockData, + accountSettings: { + ...mockData.accountSettings, + openFormId: 'name', + values: { + ...mockData.accountSettings.values, + first_name: 'first name', + last_name: 'last name', + }, + }, + }); + store.dispatch = jest.fn(store.dispatch); + + rerender(reduxWrapper()); + + const submitButton = screen.getByText('Save'); + expect(submitButton).toBeInTheDocument(); + + const firstNameText = getByLabelText('First name'); + const lastNameText = getByLabelText('Last name'); + + // Use fireEvent.change to simulate changing the selected value + fireEvent.change(firstNameText, { target: { value: 'first name' } }); + fireEvent.change(lastNameText, { target: { value: 'last name' } }); + + fireEvent.click(submitButton); + + expect(store.dispatch).toHaveBeenCalledWith(saveMultipleSettings( + [ + { + commitValues: 'first name', + formId: 'first_name', + }, + { + commitValues: 'last name', + formId: 'last_name', + }, + { + commitValues: 'first name last name', + formId: 'name', + }, + ], + 'name', + false, + )); + }, + ); + + it( + 'renders EditableField for full name if first_name and last_name are not available in account settings', + async () => { + const { getByText, rerender, getByLabelText } = render(reduxWrapper()); + + const fullNameText = getByText('Full name'); + const fullNameEditButton = fullNameText.parentElement.querySelector('button'); + + expect(fullNameEditButton).toBeInTheDocument(); + + store = mockStore({ + ...mockData, + accountSettings: { + ...mockData.accountSettings, + openFormId: 'name', + values: { + ...mockData.accountSettings.values, + }, + }, + }); + store.dispatch = jest.fn(store.dispatch); + + rerender(reduxWrapper()); + + const submitButton = screen.getByText('Save'); + expect(submitButton).toBeInTheDocument(); + + const fullName = getByLabelText('Full name'); + + // Use fireEvent.change to simulate changing the selected value + fireEvent.change(fullName, { target: { value: 'test_name' } }); + + fireEvent.click(submitButton); + + expect(store.dispatch).toHaveBeenCalledWith(saveSettings( + 'name', + 'test_name', + )); + }, + ); });