Compare commits

...

1 Commits

Author SHA1 Message Date
Syed Sajjad Hussain Shah
d67a252998 feat: add first and last name to settings page 2024-02-21 12:43:39 +05:00
14 changed files with 1068 additions and 25 deletions

View File

@@ -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}
/>
<EditableField
name="name"
type="text"
value={
{(this.props.formValues?.are_first_and_last_name_required_in_registration === true) ? (
<NameField
name="name"
type="text"
verifiedName={verifiedName}
pendingNameChange={this.props.formValues.pending_name_change}
fullNameValue={this.props.formValues.name}
firstNameValue={this.props.formValues.first_name}
lastNameValue={this.props.formValues.last_name}
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
emptyLabel={
this.isEditable('name')
? this.props.intl.formatMessage(messages['account.settings.field.full.name.empty'])
: this.renderEmptyStaticFieldMessage()
}
helpText={
verifiedName
? this.renderFullNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)
: this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])
}
isEditable={
verifiedName
? this.isEditable('verifiedName') && this.isEditable('name')
: this.isEditable('name')
}
isGrayedOut={
verifiedName && !this.isEditable('verifiedName')
}
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitFirstAndLastName}
/>
) : (
<EditableField
name="name"
type="text"
value={
verifiedName?.status === 'submitted'
&& this.props.formValues.pending_name_change
? this.props.formValues.pending_name_change
: this.props.formValues.name
}
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
emptyLabel={
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
emptyLabel={
this.isEditable('name')
? this.props.intl.formatMessage(messages['account.settings.field.full.name.empty'])
: this.renderEmptyStaticFieldMessage()
}
helpText={
helpText={
verifiedName
? this.renderFullNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)
: this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])
}
isEditable={
isEditable={
verifiedName
? this.isEditable('verifiedName') && this.isEditable('name')
: this.isEditable('name')
}
isGrayedOut={
isGrayedOut={
verifiedName && !this.isEditable('verifiedName')
}
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitProfileName}
/>
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitProfileName}
/>
)}
{verifiedName
&& (
<EditableField
@@ -872,10 +935,13 @@ AccountSettingsPage.propTypes = {
formValues: PropTypes.shape({
username: PropTypes.string,
name: PropTypes.string,
first_name: PropTypes.string,
last_name: PropTypes.string,
email: PropTypes.string,
secondary_email: PropTypes.string,
secondary_email_enabled: PropTypes.bool,
year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
are_first_and_last_name_required_in_registration: PropTypes.bool,
country: PropTypes.string,
level_of_education: PropTypes.string,
gender: PropTypes.string,

View File

@@ -81,6 +81,16 @@ const messages = defineMessages({
defaultMessage: 'Full name',
description: 'Label for account settings name field.',
},
'account.settings.field.first.name': {
id: 'account.settings.field.first.name',
defaultMessage: 'First name',
description: 'Label for account settings first name field.',
},
'account.settings.field.last.name': {
id: 'account.settings.field.last.name',
defaultMessage: 'Last name',
description: 'Label for account settings last name field.',
},
'account.settings.field.full.name.empty': {
id: 'account.settings.field.full.name.empty',
defaultMessage: 'Add name',

View File

@@ -0,0 +1,360 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Form, StatefulButton,
} from '@openedx/paragon';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SwitchContent from './SwitchContent';
import messages from './AccountSettingsPage.messages';
import {
openForm,
closeForm,
} from './data/actions';
import { nameFieldSelector } from './data/selectors';
import CertificatePreference from './certificate-preference/CertificatePreference';
/**
* This field shows concatenated user's first name and last name as their full name
* and splits the name into first name and last name fields on edit.
* @param props
* @returns {Element}
* @constructor
*/
const NameField = (props) => {
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) => {
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 <Button variant="link" onClick={handleEdit} className="p-0">{emptyLabel}</Button>;
}
return <span className="text-muted">{emptyLabel}</span>;
};
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 (
<SwitchContent
expression={isEditing ? 'editing' : 'default'}
cases={{
editing: (
<>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={id}
isInvalid={fieldError != null}
>
<Form.Group
controlId={firstNameFieldAttributes.id}
isInvalid={firstNameError || error}
className="d-inline-block mb-0"
>
<Form.Label size="sm" className="h6 d-block" htmlFor={firstNameFieldAttributes.id}>
{firstNameFieldAttributes.label}
</Form.Label>
<Form.Control
data-hj-suppress
name={firstNameFieldAttributes.name}
id={firstNameFieldAttributes.id}
type={type}
value={firstName}
onChange={(e) => { handleChange(e, firstNameFieldAttributes.name); }}
{...others}
/>
</Form.Group>
<Form.Group
controlId={lastNameFieldAttributes.id}
isInvalid={lastNameError || error}
className="d-inline-block mb-0"
>
<Form.Label size="sm" className="h6 d-block" htmlFor={lastNameFieldAttributes.id}>
{lastNameFieldAttributes.label}
</Form.Label>
<Form.Control
data-hj-suppress
name={lastNameFieldAttributes.name}
id={lastNameFieldAttributes.id}
type={type}
value={lastName}
onChange={(e) => { handleChange(e, lastNameFieldAttributes.name); }}
{...others}
/>
</Form.Group>
{!!helpText && <Form.Text>{helpText}</Form.Text>}
{fieldError != null && <Form.Control.Feedback hasIcon={false}>{fieldError}</Form.Control.Feedback>}
{others.children}
</Form.Group>
<p>
<StatefulButton
type="submit"
className="mr-2"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
}}
onClick={(e) => {
// 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={[]}
/>
<Button
variant="outline-primary"
onClick={handleCancel}
>
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
</Button>
</p>
</form>
{['name', 'verified_name'].includes(name) && <CertificatePreference fieldName={name} />}
</>
),
default: (
<div className="form-group">
<div className="d-flex align-items-start">
<h6 aria-level="3">{label}</h6>
{isEditable ? (
<Button variant="link" onClick={handleEdit} className="ml-3">
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />
{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
</Button>
) : null}
</div>
<p data-hj-suppress className={classNames('text-truncate', { 'grayed-out': isGrayedOut })}>
{renderValue(fullName)}
</p>
<p className="small text-muted mt-n2">{renderConfirmationMessage() || helpText}</p>
</div>
),
}}
/>
);
};
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));

View File

@@ -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 = () => ({

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 = () => ({

View File

@@ -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, {

View File

@@ -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;

View File

@@ -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(<IntlNameChange {...props} />));
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(<IntlNameChange {...formProps} />));
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);
},
);
});

View File

@@ -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', () => ({
@@ -63,7 +64,6 @@ describe('AccountSettingsPage', () => {
field_value: '',
},
],
},
fetchSettings: jest.fn(),
};
@@ -98,4 +98,105 @@ describe('AccountSettingsPage', () => {
fireEvent.click(submitButton);
});
it(
'renders NameField for full name if first_name and last_name are required in registration',
async () => {
const { getByText, rerender, getByLabelText } = render(reduxWrapper(<IntlAccountSettingsPage {...props} />));
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: 'John',
last_name: 'Doe',
are_first_and_last_name_required_in_registration: true,
},
},
});
store.dispatch = jest.fn(store.dispatch);
rerender(reduxWrapper(<IntlAccountSettingsPage {...props} />));
const submitButton = screen.getByText('Save');
expect(submitButton).toBeInTheDocument();
const firstNameField = getByLabelText('First name');
const lastNameField = getByLabelText('Last name');
// Use fireEvent.change to simulate changing the selected value
fireEvent.change(firstNameField, { target: { value: 'John' } });
fireEvent.change(lastNameField, { target: { value: 'Doe' } });
fireEvent.click(submitButton);
expect(store.dispatch).toHaveBeenCalledWith(saveMultipleSettings(
[
{
commitValues: 'John',
formId: 'first_name',
},
{
commitValues: 'Doe',
formId: 'last_name',
},
{
commitValues: 'John Doe',
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(<IntlAccountSettingsPage {...props} />));
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(<IntlAccountSettingsPage {...props} />));
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',
));
},
);
});

View File

@@ -0,0 +1,245 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { Provider } from 'react-redux';
import renderer from 'react-test-renderer';
import configureStore from 'redux-mock-store';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { fireEvent, render } from '@testing-library/react';
import NameField from '../NameField';
jest.mock('@edx/frontend-platform/auth');
jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
const IntlNameField = injectIntl(NameField);
const mockStore = configureStore();
describe('NameField', () => {
let props = {};
let store = {};
const reduxWrapper = children => (
<Router>
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
</Router>
);
beforeEach(() => {
store = mockStore();
const onSubmit = jest.fn();
const onChange = jest.fn();
props = {
name: 'name',
label: 'Full name',
emptyLabel: 'Add name',
type: 'text',
fullNameValue: 'Test Name',
firstNameValue: '',
lastNameValue: '',
verifiedName: null,
userSuppliedValue: '',
pendingNameChange: '',
error: '',
firstNameError: '',
lastNameError: '',
saveState: 'default',
confirmationValue: 'Confirmation Value',
helpText: 'Helpful Text',
isEditing: false,
isEditable: true,
isGrayedOut: false,
onSubmit,
onChange,
};
});
afterEach(() => jest.clearAllMocks());
it('renders NameField correctly with editing disabled', () => {
const tree = renderer.create(reduxWrapper(<IntlNameField {...props} />)).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders NameField correctly with editing enabled', () => {
props = {
...props,
isEditing: true,
};
const tree = renderer.create(reduxWrapper(<IntlNameField {...props} />)).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders NameField with an error on full name', () => {
const errorProps = {
...props,
isEditing: true,
error: 'This value is invalid.',
};
const { getByText } = render(reduxWrapper(<IntlNameField {...errorProps} />));
const errorMessage = getByText('This value is invalid.');
expect(errorMessage).toBeInTheDocument();
});
it('renders NameField with an error on first name', () => {
const errorProps = {
...props,
isEditing: true,
firstNameError: 'This value is invalid.',
};
const { getByText } = render(reduxWrapper(<IntlNameField {...errorProps} />));
const errorMessage = getByText('This value is invalid.');
expect(errorMessage).toBeInTheDocument();
});
it('renders NameField with an error on last name', () => {
const errorProps = {
...props,
isEditing: true,
lastNameError: 'This value is invalid.',
};
const { getByText } = render(reduxWrapper(<IntlNameField {...errorProps} />));
const errorMessage = getByText('This value is invalid.');
expect(errorMessage).toBeInTheDocument();
});
it('display pendingName in NameField if available when verifiedName submit state is submitted', () => {
const componentProps = {
...props,
pendingNameChange: 'Pending Name',
verifiedName: {
status: 'submitted',
},
};
const { getByText } = render(reduxWrapper(<IntlNameField {...componentProps} />));
const pendingNameText = getByText('Pending Name');
expect(pendingNameText).toBeInTheDocument();
});
it('should not display pendingName when verifiedName submit state is not submitted', () => {
const componentProps = {
...props,
pendingNameChange: 'Pending Name',
verifiedName: {
status: 'pending',
},
};
const { queryByText } = render(reduxWrapper(<IntlNameField {...componentProps} />));
expect(queryByText('Pending Name')).not.toBeInTheDocument();
});
it('display concatenated first and last name in full name if first or last name value is available', () => {
const componentProps = {
...props,
firstNameValue: 'John',
lastNameValue: 'Doe',
};
const { getByText } = render(reduxWrapper(<IntlNameField {...componentProps} />));
const pendingNameText = getByText('John Doe');
expect(pendingNameText).toBeInTheDocument();
});
it('display full name in NameField if first and last name value are not available', () => {
const componentProps = {
...props,
fullNameValue: 'John Doe',
firstNameValue: '',
lastNameValue: '',
};
const { getByText } = render(reduxWrapper(<IntlNameField {...componentProps} />));
const pendingNameText = getByText('John Doe');
expect(pendingNameText).toBeInTheDocument();
});
it('split full name in first and last name on editing if first and last name value not available ', () => {
const componentProps = {
...props,
isEditing: true,
fullNameValue: '',
firstNameValue: 'John',
lastNameValue: 'Doe',
};
const { getByLabelText } = render(reduxWrapper(<IntlNameField {...componentProps} />));
const firstNameField = getByLabelText('First name');
const lastNameField = getByLabelText('Last name');
expect(firstNameField.value).toEqual('John');
expect(lastNameField.value).toEqual('Doe');
});
it('submit full name, first name and last name on save ', () => {
const componentProps = {
...props,
isEditing: true,
fullNameValue: '',
firstNameValue: 'John',
lastNameValue: 'Doe',
};
const { getByText } = render(reduxWrapper(<IntlNameField {...componentProps} />));
const submitButton = getByText('Save');
expect(submitButton).toBeInTheDocument();
fireEvent.click(submitButton);
expect(props.onSubmit).toHaveBeenCalledWith('name', 'John Doe', 'John', 'Doe');
});
it('update both first name and full name on first name change ', () => {
const componentProps = {
...props,
isEditing: true,
fullNameValue: '',
firstNameValue: '',
lastNameValue: 'Doe',
};
const { getByLabelText } = render(reduxWrapper(<IntlNameField {...componentProps} />));
const firstNameField = getByLabelText('First name');
fireEvent.change(firstNameField, { target: { value: 'John' } });
expect(props.onChange).toHaveBeenCalledTimes(2);
expect(props.onChange).toHaveBeenCalledWith('first_name', 'John');
expect(props.onChange).toHaveBeenCalledWith('name', 'John Doe');
});
it('update both last name and full name on last name change ', () => {
const componentProps = {
...props,
isEditing: true,
fullNameValue: '',
firstNameValue: 'John',
lastNameValue: '',
};
const { getByLabelText } = render(reduxWrapper(<IntlNameField {...componentProps} />));
const lastNameField = getByLabelText('Last name');
fireEvent.change(lastNameField, { target: { value: 'Doe' } });
expect(props.onChange).toHaveBeenCalledTimes(2);
expect(props.onChange).toHaveBeenCalledWith('last_name', 'Doe');
expect(props.onChange).toHaveBeenCalledWith('name', 'John Doe');
});
});

View File

@@ -0,0 +1,189 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NameField renders NameField correctly with editing disabled 1`] = `
<div
className="pgn-transition-replace-group position-relative"
style={
Object {
"height": null,
}
}
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="form-group"
>
<div
className="d-flex align-items-start"
>
<h6
aria-level="3"
>
Full name
</h6>
<button
className="ml-3 btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="svg-inline--fa fa-pencil-alt fa-w-16 mr-1"
data-icon="pencil-alt"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M497.9 142.1l-46.1 46.1c-4.7 4.7-12.3 4.7-17 0l-111-111c-4.7-4.7-4.7-12.3 0-17l46.1-46.1c18.7-18.7 49.1-18.7 67.9 0l60.1 60.1c18.8 18.7 18.8 49.1 0 67.9zM284.2 99.8L21.6 362.4.4 483.9c-2.9 16.4 11.4 30.6 27.8 27.8l121.5-21.3 262.6-262.6c4.7-4.7 4.7-12.3 0-17l-111-111c-4.8-4.7-12.4-4.7-17.1 0zM124.1 339.9c-5.5-5.5-5.5-14.3 0-19.8l154-154c5.5-5.5 14.3-5.5 19.8 0s5.5 14.3 0 19.8l-154 154c-5.5 5.5-14.3 5.5-19.8 0zM88 424h48v36.3l-64.5 11.3-31.1-31.1L51.7 376H88v48z"
fill="currentColor"
style={Object {}}
/>
</svg>
Edit
</button>
</div>
<p
className="text-truncate"
data-hj-suppress={true}
>
Test Name
</p>
<p
className="small text-muted mt-n2"
>
Helpful Text
</p>
</div>
</div>
</div>
`;
exports[`NameField renders NameField correctly with editing enabled 1`] = `
<div
className="pgn-transition-replace-group position-relative"
style={
Object {
"height": null,
}
}
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<form
onSubmit={[Function]}
>
<div
className="pgn__form-group"
>
<div
className="pgn__form-group d-inline-block mb-0"
>
<label
className="pgn__form-label h6 d-block"
htmlFor="field-firstName"
size="sm"
>
First name
</label>
<div
className="pgn__form-control-decorator-group"
>
<input
className="has-value form-control"
data-hj-suppress={true}
id="field-firstName"
name="first_name"
onBlur={[Function]}
onChange={[Function]}
type="text"
value="Test"
/>
</div>
</div>
<div
className="pgn__form-group d-inline-block mb-0"
>
<label
className="pgn__form-label h6 d-block"
htmlFor="field-lastName"
size="sm"
>
Last name
</label>
<div
className="pgn__form-control-decorator-group"
>
<input
className="has-value form-control"
data-hj-suppress={true}
id="field-lastName"
name="last_name"
onBlur={[Function]}
onChange={[Function]}
type="text"
value="Name"
/>
</div>
</div>
<div
className="pgn__form-text pgn__form-text-default"
>
<div>
Helpful Text
</div>
</div>
<div
className="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
id="field-name-3"
>
<div>
</div>
</div>
</div>
<p>
<button
aria-disabled={false}
aria-live="assertive"
className="pgn__stateful-btn pgn__stateful-btn-state-default mr-2 btn btn-primary"
disabled={false}
onClick={[Function]}
type="submit"
>
<span
className="d-flex align-items-center justify-content-center"
>
<span>
Save
</span>
</span>
</button>
<button
className="btn btn-outline-primary"
disabled={false}
onClick={[Function]}
type="button"
>
Cancel
</button>
</p>
</form>
</div>
</div>
`;