feat: enter name change flow when submitting a new verified name

This commit is contained in:
Zachary Hancock
2021-09-27 11:33:30 -04:00
committed by GitHub
13 changed files with 204 additions and 63 deletions

View File

@@ -24,6 +24,7 @@ import {
saveMultipleSettings,
saveSettings,
updateDraft,
beginNameChange,
} from './data/actions';
import { accountSettingsPageSelector } from './data/selectors';
import PageLoading from './PageLoading';
@@ -168,7 +169,7 @@ class AccountSettingsPage extends React.Component {
this.props.saveSettings(formId, values);
}
handleSubmitName = (formId, values) => {
handleSubmitProfileName = (formId, values) => {
if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) {
this.props.saveMultipleSettings([
{
@@ -185,6 +186,15 @@ class AccountSettingsPage extends React.Component {
}
};
handleSubmitVerifiedName = (formId, values) => {
if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) {
this.props.saveSettings('useVerifiedNameForCerts', this.props.formValues.useVerifiedNameForCerts);
}
if (values !== this.props.committedValues?.verified_name) {
this.props.beginNameChange(formId);
}
}
isEditable(fieldName) {
return !this.props.staticFields.includes(fieldName);
}
@@ -249,8 +259,8 @@ class AccountSettingsPage extends React.Component {
renderFullNameHelpText = (status) => {
if (
!this.props.formValues.verifiedNameHistory
|| !this.props.formValues.verifiedNameHistory.verified_name_enabled
!this.props.verifiedNameHistory
|| !this.props.verifiedNameEnabled
) {
return this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text']);
}
@@ -375,12 +385,8 @@ class AccountSettingsPage extends React.Component {
}
renderNameChangeModal() {
const shouldDisplayNameChangeModal = (
this.props.formErrors.name
&& this.props.formErrors.name.includes('ID verification')
);
if (shouldDisplayNameChangeModal) {
return <NameChange />;
if (this.props.nameChangeModal && this.props.nameChangeModal.formId) {
return <NameChange targetFormId={this.props.nameChangeModal.formId} />;
}
return null;
}
@@ -431,8 +437,7 @@ class AccountSettingsPage extends React.Component {
// Show State field only if the country is US (could include Canada later)
const showState = this.props.formValues.country === COUNTRY_WITH_STATES;
const { verifiedName } = this.props.formValues;
const verifiedNameEnabled = this.props.formValues.verifiedNameHistory.verified_name_enabled;
const { verifiedName, verifiedNameEnabled } = this.props;
const timeZoneOptions = this.getLocalizedTimeZoneOptions(
this.props.timeZoneOptions,
@@ -444,7 +449,7 @@ class AccountSettingsPage extends React.Component {
return (
<>
<div className="account-section" id="basic-information" ref={this.navLinkRefs['#basic-information']}>
{verifiedNameEnabled && this.renderVerifiedNameMessage(this.props.formValues.mostRecentVerifiedName)}
{verifiedNameEnabled && this.renderVerifiedNameMessage(this.props.mostRecentVerifiedName)}
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.account.information'])}
@@ -471,7 +476,7 @@ class AccountSettingsPage extends React.Component {
type="text"
value={
verifiedNameEnabled
&& verifiedName.status === 'submitted'
&& verifiedName?.status === 'submitted'
&& this.props.formValues.pending_name_change
? this.props.formValues.pending_name_change
: this.props.formValues.name
@@ -496,14 +501,14 @@ class AccountSettingsPage extends React.Component {
verifiedNameEnabled && verifiedName && !this.isEditable('verifiedName')
}
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitName}
onSubmit={this.handleSubmitProfileName}
/>
{verifiedNameEnabled && verifiedName
&& (
<EditableField
name="verifiedName"
name="verified_name"
type="text"
value={this.props.formValues.verifiedName.verified_name}
value={this.props.formValues.verified_name}
label={
(
<div className="d-flex">
@@ -518,7 +523,7 @@ class AccountSettingsPage extends React.Component {
isEditable={this.isEditable('verifiedName')}
isGrayedOut={!this.isEditable('verifiedName')}
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitName}
onSubmit={this.handleSubmitVerifiedName}
/>
)}
@@ -798,27 +803,11 @@ AccountSettingsPage.propTypes = {
state: PropTypes.string,
shouldDisplayDemographicsSection: PropTypes.bool,
useVerifiedNameForCerts: PropTypes.bool.isRequired,
verifiedNameHistory: PropTypes.shape({
verified_name_enabled: PropTypes.bool,
use_verified_name_for_certs: PropTypes.bool,
results: PropTypes.arrayOf(
PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
}),
),
}),
verifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
}),
mostRecentVerifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
}),
verified_name: PropTypes.string,
}).isRequired,
committedValues: PropTypes.shape({
useVerifiedNameForCerts: PropTypes.bool,
verified_name: PropTypes.string,
}),
drafts: PropTypes.shape({}),
formErrors: PropTypes.shape({
@@ -850,7 +839,28 @@ AccountSettingsPage.propTypes = {
saveMultipleSettings: PropTypes.func.isRequired,
saveSettings: PropTypes.func.isRequired,
fetchSettings: PropTypes.func.isRequired,
tpaProviders: PropTypes.arrayOf(PropTypes.object),
beginNameChange: PropTypes.func.isRequired,
tpaProviders: PropTypes.arrayOf(PropTypes.shape({
connected: PropTypes.bool,
})),
nameChangeModal: PropTypes.shape({
formId: PropTypes.string,
}),
verifiedNameEnabled: PropTypes.bool,
verifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
}),
mostRecentVerifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
}),
verifiedNameHistory: PropTypes.arrayOf(
PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
}),
),
};
AccountSettingsPage.defaultProps = {
@@ -859,6 +869,7 @@ AccountSettingsPage.defaultProps = {
loadingError: null,
committedValues: {
useVerifiedNameForCerts: false,
verified_name: null,
},
drafts: {},
formErrors: {},
@@ -871,6 +882,11 @@ AccountSettingsPage.defaultProps = {
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
nameChangeModal: {},
verifiedNameEnabled: false,
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: [],
};
export default connect(accountSettingsPageSelector, {
@@ -879,4 +895,5 @@ export default connect(accountSettingsPageSelector, {
saveMultipleSettings,
updateDraft,
fetchSiteLanguages,
beginNameChange,
})(injectIntl(AccountSettingsPage));

View File

@@ -121,6 +121,11 @@ const messages = defineMessages({
defaultMessage: 'Verification has been submitted. This usually takes 48 hours or less. Verified name cannot be changed at this time.',
description: 'Help text for the account settings verified name field when a verified name has been submitted.',
},
'account.settings.field.name.verified.verification.alert': {
id: 'account.settings.field.name.verified.verification.help',
defaultMessage: 'Enter your name as it appears on your government-issued ID.',
description: 'Form label instructing the user to enter the name on their ID.',
},
'account.settings.field.full.name.help.text.submitted': {
id: 'account.settings.field.full.name.help.text.submitted',
defaultMessage: 'When identity verification is successful, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.',

View File

@@ -153,7 +153,7 @@ function EditableField(props) {
</Button>
</p>
</form>
{['name', 'verifiedName'].includes(name) && <CertificatePreference fieldName={name} />}
{['name', 'verified_name'].includes(name) && <CertificatePreference fieldName={name} />}
</>
),
default: (

View File

@@ -42,7 +42,7 @@ function CertificatePreference({
function handleCheckboxChange() {
if (!checked) {
if (fieldName === 'verifiedName') {
if (fieldName === 'verified_name') {
dispatch(updateDraft(formId, true));
} else {
dispatch(updateDraft(formId, false));

View File

@@ -9,6 +9,7 @@ export const OPEN_FORM = 'OPEN_FORM';
export const CLOSE_FORM = 'CLOSE_FORM';
export const UPDATE_DRAFT = 'UPDATE_DRAFT';
export const RESET_DRAFTS = 'RESET_DRAFTS';
export const BEGIN_NAME_CHANGE = 'BEGIN_NAME_CHANGE';
// FETCH SETTINGS ACTIONS
@@ -25,6 +26,7 @@ export const fetchSettingsSuccess = ({
thirdPartyAuthProviders,
profileDataManager,
timeZones,
verifiedNameHistory,
}) => ({
type: FETCH_SETTINGS.SUCCESS,
payload: {
@@ -32,6 +34,7 @@ export const fetchSettingsSuccess = ({
thirdPartyAuthProviders,
profileDataManager,
timeZones,
verifiedNameHistory,
},
});
@@ -68,6 +71,10 @@ export const resetDrafts = () => ({
type: RESET_DRAFTS,
});
export const beginNameChange = (formId) => ({
type: BEGIN_NAME_CHANGE,
payload: { formId },
});
// SAVE SETTINGS ACTIONS
export const saveSettings = (formId, commitValues) => ({

View File

@@ -8,6 +8,7 @@ import {
UPDATE_DRAFT,
RESET_DRAFTS,
SAVE_MULTIPLE_SETTINGS,
BEGIN_NAME_CHANGE,
} from './actions';
import { reducer as deleteAccountReducer, DELETE_ACCOUNT } from '../delete-account';
@@ -34,6 +35,11 @@ export const defaultState = {
resetPassword: resetPasswordReducer(),
nameChange: nameChangeReducer(),
thirdPartyAuth: thirdPartyAuthReducer(),
nameChangeModal: false,
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: {},
verifiedNameEnabled: false,
};
const reducer = (state = defaultState, action) => {
@@ -58,6 +64,7 @@ const reducer = (state = defaultState, action) => {
loading: false,
loaded: true,
loadingError: null,
verifiedNameHistory: action.payload.verifiedNameHistory,
};
case FETCH_SETTINGS.FAILURE:
return {
@@ -91,6 +98,7 @@ const reducer = (state = defaultState, action) => {
saveState: null,
errors: {},
drafts: {},
nameChangeModal: false,
};
}
return state;
@@ -108,6 +116,15 @@ const reducer = (state = defaultState, action) => {
drafts: {},
};
case BEGIN_NAME_CHANGE:
return {
...state,
saveState: 'error',
nameChangeModal: {
formId: action.payload.formId,
},
};
case SAVE_SETTINGS.BEGIN:
return {
...state,
@@ -121,7 +138,6 @@ const reducer = (state = defaultState, action) => {
values: { ...state.values, ...action.payload.values },
errors: {},
confirmationValues: {
...state.confirmationValues,
...action.payload.confirmationValues,
},

View File

@@ -25,6 +25,7 @@ import {
saveMultipleSettingsBegin,
saveMultipleSettingsSuccess,
saveMultipleSettingsFailure,
beginNameChange,
} from './actions';
// Sub-modules
@@ -43,6 +44,7 @@ import {
getSettings,
patchSettings,
getTimeZones,
getVerifiedNameHistory,
} from './service';
export function* handleFetchSettings() {
@@ -59,6 +61,8 @@ export function* handleFetchSettings() {
userId,
);
const verifiedNameHistory = yield call(getVerifiedNameHistory);
if (values.country) { yield put(fetchTimeZones(values.country)); }
yield put(fetchSettingsSuccess({
@@ -66,6 +70,7 @@ export function* handleFetchSettings() {
thirdPartyAuthProviders,
profileDataManager,
timeZones,
verifiedNameHistory,
}));
} catch (e) {
yield put(fetchSettingsFailure(e.message));
@@ -103,6 +108,9 @@ export function* handleSaveSettings(action) {
yield put(closeForm(action.payload.formId));
} catch (e) {
if (e.fieldErrors) {
if (Object.keys(e.fieldErrors).includes('name')) {
yield put(beginNameChange('name'));
}
yield put(saveSettingsFailure({ fieldErrors: e.fieldErrors }));
} else {
yield put(saveSettingsFailure(e.message));
@@ -131,6 +139,9 @@ export function* handleSaveMultipleSettings(action) {
}
} catch (e) {
if (e.fieldErrors) {
if (Object.keys(e.fieldErrors).includes('name')) {
yield put(beginNameChange('name'));
}
yield put(saveMultipleSettingsFailure({ fieldErrors: e.fieldErrors }));
} else {
yield put(saveMultipleSettingsFailure(e.message));

View File

@@ -8,10 +8,19 @@ export const accountSettingsSelector = state => ({ ...state[storeName] });
const editableFieldNameSelector = (state, props) => props.name;
const sortedVerifiedNameHistorySelector = createSelector(
const verifiedNameSettingsSelector = createSelector(
accountSettingsSelector,
accountSettings => {
const history = accountSettings.values.verifiedNameHistory && accountSettings.values.verifiedNameHistory.results;
accountSettings => ({
history: accountSettings.verifiedNameHistory.results,
verifiedNameEnabled: accountSettings?.verifiedNameHistory.verified_name_enabled,
useVerifiedNameForCerts: accountSettings?.verifiedNameHistory.use_verified_name_for_certs,
}),
);
const sortedVerifiedNameHistorySelector = createSelector(
verifiedNameSettingsSelector,
verifiedNameSettings => {
const { history } = verifiedNameSettings;
if (Array.isArray(history)) {
return history.sort(compareVerifiedNamesByCreatedDate);
@@ -51,9 +60,8 @@ const mostRecentApprovedVerifiedNameValueSelector = createSelector(
const valuesSelector = createSelector(
accountSettingsSelector,
mostRecentVerifiedNameSelector,
mostRecentApprovedVerifiedNameValueSelector,
(accountSettings, mostRecentVerifiedNameValue, mostRecentApprovedVerifiedNameValue) => {
(accountSettings, mostRecentApprovedVerifiedNameValue) => {
let useVerifiedNameForCerts = (
accountSettings.values.verifiedNameHistory?.use_verified_name_for_certs || false
);
@@ -64,8 +72,7 @@ const valuesSelector = createSelector(
return {
...accountSettings.values,
verifiedName: mostRecentApprovedVerifiedNameValue,
mostRecentVerifiedName: mostRecentVerifiedNameValue,
verified_name: mostRecentApprovedVerifiedNameValue?.verified_name,
useVerifiedNameForCerts,
};
},
@@ -109,6 +116,11 @@ const errorSelector = createSelector(
accountSettings => accountSettings.errors,
);
const nameChangeModalSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.nameChangeModal,
);
const saveStateSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.saveState,
@@ -212,6 +224,11 @@ export const accountSettingsPageSelector = createSelector(
timeZonesSelector,
countryTimeZonesSelector,
activeAccountSelector,
nameChangeModalSelector,
mostRecentApprovedVerifiedNameValueSelector,
mostRecentVerifiedNameSelector,
sortedVerifiedNameHistorySelector,
verifiedNameSettingsSelector,
(
accountSettings,
siteLanguageOptions,
@@ -225,6 +242,11 @@ export const accountSettingsPageSelector = createSelector(
timeZoneOptions,
countryTimeZoneOptions,
activeAccount,
nameChangeModal,
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
verifiedNameSettings,
) => ({
siteLanguageOptions,
siteLanguage,
@@ -241,16 +263,23 @@ export const accountSettingsPageSelector = createSelector(
profileDataManager,
staticFields,
tpaProviders: accountSettings.thirdPartyAuth.providers,
nameChangeModal,
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
verifiedNameEnabled: verifiedNameSettings?.verifiedNameEnabled,
}),
);
export const certPreferenceSelector = createSelector(
verifiedNameSettingsSelector,
valuesSelector,
formValuesSelector,
mostRecentApprovedVerifiedNameValueSelector,
saveStateSelector,
errorSelector,
(
verifiedNameSettings,
committedValues,
formValues,
mostRecentApprovedVerifiedNameValue,
@@ -261,7 +290,7 @@ export const certPreferenceSelector = createSelector(
originalVerifiedName: mostRecentApprovedVerifiedNameValue?.verified_name || '',
useVerifiedNameForCerts: formValues.useVerifiedNameForCerts || false,
saveState,
verifiedNameEnabled: formValues.verifiedNameHistory?.verified_name_enabled || false,
verifiedNameEnabled: verifiedNameSettings.verifiedNameEnabled || false,
formErrors: errors,
}),
);

View File

@@ -240,7 +240,6 @@ export async function getSettings(username, userRoles, userId) {
shouldDisplayDemographicsQuestionsResponse,
demographics,
demographicsOptions,
verifiedNameHistory,
] = await Promise.all([
getAccount(username),
getPreferences(username),
@@ -251,7 +250,6 @@ export async function getSettings(username, userRoles, userId) {
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && shouldDisplayDemographicsQuestions(),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographics(userId),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographicsOptions(),
getVerifiedNameHistory(),
]);
return {
@@ -264,7 +262,6 @@ export async function getSettings(username, userRoles, userId) {
shouldDisplayDemographicsSection: shouldDisplayDemographicsQuestionsResponse,
...demographics,
demographicsOptions,
verifiedNameHistory,
};
}

View File

@@ -22,6 +22,7 @@ import { requestNameChange, requestNameChangeFailure, requestNameChangeReset } f
import messages from './messages';
function NameChangeModal({
targetFormId,
errors,
formValues,
intl,
@@ -30,7 +31,7 @@ function NameChangeModal({
const dispatch = useDispatch();
const { push } = useHistory();
const { username } = getAuthenticatedUser();
const [verifiedNameInput, setVerifiedNameInput] = useState('');
const [verifiedNameInput, setVerifiedNameInput] = useState(formValues.verified_name || '');
const [confirmedWarning, setConfirmedWarning] = useState(false);
function resetLocalState() {
@@ -44,7 +45,7 @@ function NameChangeModal({
function handleClose() {
resetLocalState();
dispatch(closeForm('name'));
dispatch(closeForm(targetFormId));
dispatch(saveSettingsReset());
}
@@ -60,7 +61,8 @@ function NameChangeModal({
verified_name: intl.formatMessage(messages['account.settings.name.change.error.valid.name']),
}));
} else {
dispatch(requestNameChange(username, formValues.name, verifiedNameInput));
const draftProfileName = targetFormId === 'name' ? formValues.name : null;
dispatch(requestNameChange(username, draftProfileName, verifiedNameInput));
}
}
@@ -184,8 +186,12 @@ function NameChangeModal({
}
NameChangeModal.propTypes = {
targetFormId: PropTypes.string.isRequired,
errors: PropTypes.shape({}).isRequired,
formValues: PropTypes.shape({ name: PropTypes.string }).isRequired,
formValues: PropTypes.shape({
name: PropTypes.string,
verified_name: PropTypes.string,
}).isRequired,
saveState: PropTypes.string,
intl: intlShape.isRequired,
};

View File

@@ -2,9 +2,9 @@ import { AsyncActionType } from '../../data/utils';
export const REQUEST_NAME_CHANGE = new AsyncActionType('ACCOUNT_SETTINGS', 'REQUEST_NAME_CHANGE');
export const requestNameChange = (username, newName, verifiedName) => ({
export const requestNameChange = (username, profileName, verifiedName) => ({
type: REQUEST_NAME_CHANGE.BASE,
payload: { username, newName, verifiedName },
payload: { username, profileName, verifiedName },
});
export const requestNameChangeBegin = () => ({

View File

@@ -1,5 +1,7 @@
import { put, call, takeEvery } from 'redux-saga/effects';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { postVerifiedName } from '../../data/service';
import {
@@ -11,13 +13,17 @@ import {
import { postNameChange } from './service';
export function* handleRequestNameChange(action) {
let { name: profileName } = getAuthenticatedUser();
try {
yield put(requestNameChangeBegin());
yield call(postNameChange, action.payload.newName);
if (action.payload.profileName) {
yield call(postNameChange, action.payload.profileName);
profileName = action.payload.profileName;
}
yield call(postVerifiedName, {
username: action.payload.username,
verified_name: action.payload.verifiedName,
profile_name: action.payload.newName,
profile_name: profileName,
});
yield put(requestNameChangeSuccess());
} catch (err) {

View File

@@ -48,8 +48,12 @@ describe('NameChange', () => {
beforeEach(() => {
store = mockStore();
props = {
targetFormId: 'test_form',
errors: {},
formValues: { name: 'edx edx' },
formValues: {
name: 'edx edx',
verified_name: 'edX Verified',
},
saveState: null,
intl: {},
};
@@ -65,7 +69,7 @@ describe('NameChange', () => {
afterEach(() => jest.clearAllMocks());
it('renders input after clicking continue', async () => {
it('renders populated input after clicking continue if verified_name in form data', async () => {
const getInput = () => screen.queryByPlaceholderText('Enter the name on your government ID');
render(reduxWrapper(<IntlNameChange {...props} />));
@@ -74,13 +78,29 @@ describe('NameChange', () => {
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
expect(getInput()).toBeTruthy();
expect(getInput().value).toBe('edX Verified');
});
it('dispatches action on submit', async () => {
it('renders empty input after clicking continue if verified_name not in form data', async () => {
const getInput = () => screen.queryByPlaceholderText('Enter the name on your government ID');
const formProps = {
...props,
formValues: {
name: 'edx edx',
},
};
render(reduxWrapper(<IntlNameChange {...formProps} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
expect(getInput().value).toBe('');
});
it('dispatches verifiedName on submit if targetForm is not "name"', async () => {
const dispatchData = {
payload: {
newName: 'edx edx',
profileName: null,
username: 'edx',
verifiedName: 'Verified Name',
},
@@ -100,6 +120,33 @@ describe('NameChange', () => {
expect(mockDispatch).toHaveBeenCalledWith(dispatchData);
});
it('dispatches both profileName and verifiedName on submit if the targetForm is "name"', async () => {
const dispatchData = {
payload: {
profileName: 'edx edx',
username: 'edx',
verifiedName: 'Verified Name',
},
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
};
const formProps = {
...props,
targetFormId: 'name',
};
render(reduxWrapper(<IntlNameChange {...formProps} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
const input = screen.getByPlaceholderText('Enter the name on your government ID');
fireEvent.change(input, { target: { value: 'Verified Name' } });
const submitButton = screen.getByText('Continue');
fireEvent.click(submitButton);
expect(mockDispatch).toHaveBeenCalledWith(dispatchData);
});
it('does not dispatch action while pending', async () => {
props.saveState = 'pending';