feat: include pending or approved verified names in the name field if they exist

MST-1016: https://openedx.atlassian.net/browse/MST-1016

If a learner has a pending or approved verified name, the most recent should take precedence over the profile name on the GetNameIdPanel during the "Account Name Check". If the learner has no pending or approved verified names, then the learner's profile name should be used instead.
This commit is contained in:
michaelroytman
2021-09-21 14:07:49 -04:00
parent a6d265b885
commit 1c0dc36907
9 changed files with 300 additions and 87 deletions

View File

@@ -1,5 +1,6 @@
import { createSelector, createStructuredSelector } from 'reselect';
import { siteLanguageListSelector, siteLanguageOptionsSelector } from '../site-language';
import { compareVerifiedNamesByCreatedDate } from '../../utils';
export const storeName = 'accountSettings';
@@ -10,16 +11,10 @@ const editableFieldNameSelector = (state, props) => props.name;
const sortedVerifiedNameHistorySelector = createSelector(
accountSettingsSelector,
accountSettings => {
function sortDates(a, b) {
const aTimeSinceEpoch = new Date(a).getTime();
const bTimeSinceEpoch = new Date(b).getTime();
return bTimeSinceEpoch - aTimeSinceEpoch;
}
const history = accountSettings.values.verifiedNameHistory && accountSettings.values.verifiedNameHistory.results;
if (Array.isArray(history)) {
return history.sort(sortDates);
return history.sort(compareVerifiedNamesByCreatedDate);
}
return [];

View File

@@ -2,16 +2,18 @@ import React, { useState, useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import { AppContext } from '@edx/frontend-platform/react';
import { getProfileDataManager, getVerifiedName, getVerifiedNameEnabled } from '../account-settings/data/service';
import { getProfileDataManager } from '../account-settings/data/service';
import PageLoading from '../account-settings/PageLoading';
import { getExistingIdVerification, getEnrollments } from './data/service';
import AccessBlocked from './AccessBlocked';
import { hasGetUserMediaSupport } from './getUserMediaShim';
import IdVerificationContext, { MEDIA_ACCESS, ERROR_REASONS, VERIFIED_MODES } from './IdVerificationContext';
import { VerifiedNameContext } from './VerifiedNameContext';
export default function IdVerificationContextProvider({ children }) {
const { authenticatedUser } = useContext(AppContext);
const { verifiedName, verifiedNameEnabled } = useContext(VerifiedNameContext);
const [existingIdVerification, setExistingIdVerification] = useState(null);
useEffect(() => {
@@ -30,19 +32,6 @@ export default function IdVerificationContextProvider({ children }) {
hasGetUserMediaSupport ? MEDIA_ACCESS.PENDING : MEDIA_ACCESS.UNSUPPORTED,
);
const [verifiedNameEnabled, setVerifiedNameEnabled] = useState(false);
useEffect(() => {
// Make the API call to retrieve VerifiedNameEnabled
(async () => {
const response = await getVerifiedNameEnabled();
if (response) {
setVerifiedNameEnabled(response.verified_name_enabled);
} else {
setVerifiedNameEnabled(false);
}
})();
}, []);
const [canVerify, setCanVerify] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
@@ -90,19 +79,6 @@ export default function IdVerificationContextProvider({ children }) {
}
}, [authenticatedUser]);
const [verifiedName, setVerifiedName] = useState('');
useEffect(() => {
// Make the API call to retrieve VerifiedName of the learner.
// If the learner do not have such attribute from their account, that's OK.
// If the learner do have the attribute, the VerifiedName is overriding authenticatedUser.name
(async () => {
const verifiedNameResponse = await getVerifiedName();
if (verifiedNameResponse) {
setVerifiedName(verifiedNameResponse.verified_name);
}
})();
}, []);
const [optimizelyExperimentName, setOptimizelyExperimentName] = useState('');
const [shouldUseCamera, setShouldUseCamera] = useState(false);
@@ -122,6 +98,8 @@ export default function IdVerificationContextProvider({ children }) {
mediaStream,
mediaAccess,
userId: authenticatedUser.userId,
// If the learner has an applicable verified name, then this should override authenticatedUser.name
// when determining the context value nameOnAccount.
nameOnAccount: verifiedName || authenticatedUser.name,
profileDataManager,
optimizelyExperimentName,

View File

@@ -11,6 +11,7 @@ import { idVerificationSelector } from './data/selectors';
import './getUserMediaShim';
import IdVerificationContextProvider from './IdVerificationContextProvider';
import { VerifiedNameContextProvider } from './VerifiedNameContext';
import ReviewRequirementsPanel from './panels/ReviewRequirementsPanel';
import ChooseModePanel from './panels/ChooseModePanel';
import RequestCameraAccessPanel from './panels/RequestCameraAccessPanel';
@@ -51,20 +52,22 @@ function IdVerificationPage(props) {
<div className="page__id-verification container-fluid py-5">
<div className="row">
<div className="col-lg-6 col-md-8">
<IdVerificationContextProvider>
<Switch>
<Route path={`${path}/review-requirements`} component={ReviewRequirementsPanel} />
<Route path={`${path}/choose-mode`} component={ChooseModePanel} />
<Route path={`${path}/request-camera-access`} component={RequestCameraAccessPanel} />
<Route path={`${path}/portrait-photo-context`} component={PortraitPhotoContextPanel} />
<Route path={`${path}/take-portrait-photo`} component={TakePortraitPhotoPanel} />
<Route path={`${path}/id-context`} component={IdContextPanel} />
<Route path={`${path}/get-name-id`} component={GetNameIdPanel} />
<Route path={`${path}/take-id-photo`} component={TakeIdPhotoPanel} />
<Route path={`${path}/summary`} component={SummaryPanel} />
<Route path={`${path}/submitted`} component={SubmittedPanel} />
</Switch>
</IdVerificationContextProvider>
<VerifiedNameContextProvider>
<IdVerificationContextProvider>
<Switch>
<Route path={`${path}/review-requirements`} component={ReviewRequirementsPanel} />
<Route path={`${path}/choose-mode`} component={ChooseModePanel} />
<Route path={`${path}/request-camera-access`} component={RequestCameraAccessPanel} />
<Route path={`${path}/portrait-photo-context`} component={PortraitPhotoContextPanel} />
<Route path={`${path}/take-portrait-photo`} component={TakePortraitPhotoPanel} />
<Route path={`${path}/id-context`} component={IdContextPanel} />
<Route path={`${path}/get-name-id`} component={GetNameIdPanel} />
<Route path={`${path}/take-id-photo`} component={TakeIdPhotoPanel} />
<Route path={`${path}/summary`} component={SummaryPanel} />
<Route path={`${path}/submitted`} component={SubmittedPanel} />
</Switch>
</IdVerificationContextProvider>
</VerifiedNameContextProvider>
</div>
<div className="col-lg-6 col-md-4 pt-md-0 pt-4 text-right">
<Button variant="link" className="px-0" onClick={() => setIsModalOpen(true)}>

View File

@@ -0,0 +1,40 @@
import React, { createContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { getVerifiedNameHistory } from '../account-settings/data/service';
import { getMostRecentApprovedOrPendingVerifiedName } from '../utils';
export const VerifiedNameContext = createContext();
export function VerifiedNameContextProvider({ children }) {
const [verifiedNameEnabled, setVerifiedNameEnabled] = useState(false);
const [verifiedName, setVerifiedName] = useState('');
useEffect(() => {
// Make API call to retrieve VerifiedName history for the learner.
// From this information, derive whether the verified name feature is enabled
// and the learner's verified name as it should be displayed during the IDV process.
(async () => {
const response = await getVerifiedNameHistory();
if (response) {
const { verified_name_enabled: verifiedNameFeatureEnabled, results } = response;
setVerifiedNameEnabled(verifiedNameFeatureEnabled);
if (verifiedNameFeatureEnabled) {
const applicableVerifiedName = getMostRecentApprovedOrPendingVerifiedName(results);
setVerifiedName(applicableVerifiedName);
}
}
})();
}, []);
const value = {
verifiedNameEnabled,
verifiedName,
};
return (<VerifiedNameContext.Provider value={value}>{children}</VerifiedNameContext.Provider>);
}
VerifiedNameContextProvider.propTypes = {
children: PropTypes.node.isRequired,
};

View File

@@ -5,15 +5,15 @@ import '@testing-library/jest-dom/extend-expect';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { getProfileDataManager, getVerifiedName, getVerifiedNameEnabled } from '../../account-settings/data/service';
import { getProfileDataManager } from '../../account-settings/data/service';
import { getExistingIdVerification, getEnrollments } from '../data/service';
import IdVerificationContextProvider from '../IdVerificationContextProvider';
import { VerifiedNameContext } from '../VerifiedNameContext';
jest.mock('../../account-settings/data/service', () => ({
getProfileDataManager: jest.fn(),
getVerifiedName: jest.fn(),
getVerifiedNameEnabled: jest.fn(),
getVerifiedNameHistory: jest.fn(),
}));
jest.mock('../data/service', () => ({
@@ -32,12 +32,15 @@ describe('IdVerificationContextProvider', () => {
});
it('renders correctly and calls getExistingIdVerification + getEnrollments', async () => {
const context = { authenticatedUser: { userId: 3, roles: [] } };
const appContext = { authenticatedUser: { userId: 3, roles: [] } };
const verifiedNameContext = { verifiedName: '', verifiedNameEnabled: false };
await act(async () => render((
<AppContext.Provider value={context}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
<AppContext.Provider value={appContext}>
<VerifiedNameContext.Provider value={verifiedNameContext}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
</VerifiedNameContext.Provider>
</AppContext.Provider>
)));
expect(getExistingIdVerification).toHaveBeenCalled();
@@ -45,47 +48,26 @@ describe('IdVerificationContextProvider', () => {
});
it('calls getProfileDataManager if the user has any roles', async () => {
const context = {
const appContext = {
authenticatedUser: {
userId: 3,
username: 'testname',
roles: ['enterprise_learner'],
},
};
const verifiedNameContext = { verifiedName: '', verifiedNameEnabled: false };
await act(async () => render((
<AppContext.Provider value={context}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
<AppContext.Provider value={appContext}>
<VerifiedNameContext.Provider value={verifiedNameContext}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
</VerifiedNameContext.Provider>
</AppContext.Provider>
)));
expect(getProfileDataManager).toHaveBeenCalledWith(
context.authenticatedUser.username,
context.authenticatedUser.roles,
appContext.authenticatedUser.username,
appContext.authenticatedUser.roles,
);
});
it('calls getVerifiedName', async () => {
const context = { authenticatedUser: { userId: 3, roles: [] } };
await act(async () => render((
<AppContext.Provider value={context}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
</AppContext.Provider>
)));
expect(getVerifiedName).toHaveBeenCalled();
});
it('calls getVerifiedNameEnabled', async () => {
const context = { authenticatedUser: { userId: 3, roles: [] } };
await act(async () => render((
<AppContext.Provider value={context}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
</AppContext.Provider>
)));
expect(getVerifiedNameEnabled).toHaveBeenCalled();
});
});

View File

@@ -11,6 +11,13 @@ import * as selectors from '../data/selectors';
jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ idVerificationSelector: () => ({}) })));
jest.mock('../IdVerificationContextProvider', () => jest.fn(({ children }) => children));
jest.mock('../VerifiedNameContext', () => {
const originalModule = jest.requireActual('../VerifiedNameContext');
return {
...originalModule,
VerifiedNameContextProvider: jest.fn(({ children }) => children),
};
});
jest.mock('../panels/ReviewRequirementsPanel');
jest.mock('../panels/RequestCameraAccessPanel');
jest.mock('../panels/PortraitPhotoContextPanel');

View File

@@ -0,0 +1,85 @@
import React, { useContext } from 'react';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { getVerifiedNameHistory } from '../../account-settings/data/service';
import { VerifiedNameContext, VerifiedNameContextProvider } from '../VerifiedNameContext';
const VerifiedNameContextTestComponent = () => {
const { verifiedName, verifiedNameEnabled } = useContext(VerifiedNameContext);
return (
<>
{verifiedNameEnabled && (<div data-testid="verified-name">{verifiedName}</div>)}
<div data-testid="verified-name-enabled">{verifiedNameEnabled ? 'true' : 'false'}</div>
</>
);
};
jest.mock('../../account-settings/data/service', () => ({
getVerifiedNameHistory: jest.fn(),
}));
describe('VerifiedNameContextProvider', () => {
const defaultProps = {
children: <div />,
intl: {},
};
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
it('calls getVerifiedNameHistory', async () => {
jest.mock('../../account-settings/data/service', () => ({
getVerifiedNameHistory: jest.fn(),
}));
render(<VerifiedNameContextProvider {...defaultProps} />);
expect(getVerifiedNameHistory).toHaveBeenCalledTimes(1);
});
it('sets verifiedName and verifiedNameEnabled correctly when verified name feature enabled', async () => {
const mockReturnValue = {
verified_name_enabled: true,
results: [{
verified_name: 'Michael',
status: 'approved',
created: '2021-08-31T18:33:32.489200Z',
}],
};
getVerifiedNameHistory.mockReturnValueOnce(mockReturnValue);
const { getByTestId } = render((
<VerifiedNameContextProvider {...defaultProps}>
<VerifiedNameContextTestComponent />
</VerifiedNameContextProvider>
));
await waitFor(() => expect(getVerifiedNameHistory).toHaveBeenCalledTimes(1));
expect(getByTestId('verified-name')).toHaveTextContent('Michael');
expect(getByTestId('verified-name-enabled')).toHaveTextContent('true');
});
it('sets verifiedName and verifiedNameEnabled correctly when verified name feature not enabled', async () => {
const mockReturnValue = {
verified_name_enabled: false,
results: [{
verified_name: 'Michael',
status: 'approved',
created: '2021-08-31T18:33:32.489200Z',
}],
};
getVerifiedNameHistory.mockReturnValueOnce(mockReturnValue);
const { queryByTestId } = render((
<VerifiedNameContextProvider {...defaultProps}>
<VerifiedNameContextTestComponent />
</VerifiedNameContextProvider>
));
await waitFor(() => expect(getVerifiedNameHistory).toHaveBeenCalledTimes(1));
expect(queryByTestId('verified-name')).toBeNull();
expect(queryByTestId('verified-name-enabled')).toHaveTextContent('false');
});
});

83
src/tests/utils.test.js Normal file
View File

@@ -0,0 +1,83 @@
import { compareVerifiedNamesByCreatedDate, getMostRecentApprovedOrPendingVerifiedName } from '../utils';
describe('getMostRecentApprovedOrPendingVerifiedName', () => {
it('returns correct verified name if one exists', () => {
const verifiedNames = [
{
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Mike',
status: 'denied',
},
{
created: '2021-09-03T18:33:32.489200Z',
verified_name: 'Michelangelo',
status: 'approved',
},
];
expect(getMostRecentApprovedOrPendingVerifiedName(verifiedNames)).toEqual(verifiedNames[1].verified_name);
});
it('returns no verified name if one does not exist', () => {
const verifiedNames = [
{
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Mike',
status: 'denied',
},
{
created: '2021-09-03T18:33:32.489200Z',
verified_name: 'Michelangelo',
status: 'submitted',
},
];
expect(getMostRecentApprovedOrPendingVerifiedName(verifiedNames)).toBeNull();
});
});
describe('compareVerifiedNamesByCreatedDate', () => {
it('returns 0 when equal', () => {
const a = {
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Mike',
status: 'denied',
};
const b = {
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Michael',
status: 'denied',
};
expect(compareVerifiedNamesByCreatedDate(a, b)).toEqual(0);
});
it('returns negative number when first argument is greater than second argument', () => {
const a = {
created: '2021-09-30T18:33:32.489200Z',
verified_name: 'Mike',
status: 'denied',
};
const b = {
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Michael',
status: 'denied',
};
expect(compareVerifiedNamesByCreatedDate(a, b)).toBeLessThan(0);
});
it('returns positive number when first argument is less than second argument', () => {
const a = {
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Mike',
status: 'denied',
};
const b = {
created: '2021-09-30T18:33:32.489200Z',
verified_name: 'Michael',
status: 'denied',
};
expect(compareVerifiedNamesByCreatedDate(a, b)).toBeGreaterThan(0);
});
});

40
src/utils.js Normal file
View File

@@ -0,0 +1,40 @@
/**
* Compare two dates.
* @param {*} a the first date
* @param {*} b the second date
* @returns a negative integer if a > b, a positive integer if a < b, or 0 if a = b
*/
export function compareVerifiedNamesByCreatedDate(a, b) {
const aTimeSinceEpoch = new Date(a.created).getTime();
const bTimeSinceEpoch = new Date(b.created).getTime();
return bTimeSinceEpoch - aTimeSinceEpoch;
}
/**
*
* @param {*} verifiedNames a list of verified name objects, where each object has at least the
* following keys: created, status, and verified_name.
* @returns the most recent verified name object from the list parameter with the 'pending' or
* 'accepted' status, if one exists; otherwise, null
*/
export function getMostRecentApprovedOrPendingVerifiedName(verifiedNames) {
// clone array so as not to modify original array
const names = [...verifiedNames];
if (Array.isArray(names)) {
names.sort(compareVerifiedNamesByCreatedDate);
}
// We only want to consider a subset of verified names when determining the value of nameOnAccount.
// approved: consider this status, as the name has been verified by IDV and should supersede the full name
// (profile name).
// pending: consider this status, as the learner has started the name change process through the
// Account Settings page, and has been navigated to IDV to complete the name change process.
// submitted: do not consider this status, as the name has already been submitted for verification through
// IDV but has not yet been verified
// denied: do not consider this status because the name was already denied via the IDV process
const applicableNames = names.filter(name => ['approved', 'pending'].includes(name.status));
const applicableName = applicableNames.length > 0 ? applicableNames[0].verified_name : null;
return applicableName;
}