From b8ab0a215008501c3d420c5f196dc844446663d6 Mon Sep 17 00:00:00 2001 From: Justin Hynes Date: Tue, 23 Jun 2020 09:58:15 -0400 Subject: [PATCH] MICROBA-309 | DemographicsSection component tests and error handling [MICROBA-309] - Fix defect in ethnicityFieldDisplay function. Check to make sure key/field exists before accessing - Add additional error handling. Add ability to show an Alert to the user if a call to the Demographics API fails - Add tests --- src/account-settings/data/selectors.js | 6 +- .../demographics/Checkboxes.jsx | 2 +- .../demographics/DemographicsSection.jsx | 102 +- .../demographics/data/service.js | 45 +- .../test/DemographicsSection.test.jsx | 128 + .../DemographicsSection.test.jsx.snap | 3713 +++++++++++++++++ 6 files changed, 3962 insertions(+), 34 deletions(-) create mode 100644 src/account-settings/demographics/test/DemographicsSection.test.jsx create mode 100644 src/account-settings/demographics/test/__snapshots__/DemographicsSection.test.jsx.snap diff --git a/src/account-settings/data/selectors.js b/src/account-settings/data/selectors.js index d441dd2..ac29cad 100644 --- a/src/account-settings/data/selectors.js +++ b/src/account-settings/data/selectors.js @@ -1,6 +1,5 @@ import { createSelector, createStructuredSelector } from 'reselect'; - -import { siteLanguageOptionsSelector, siteLanguageListSelector } from '../site-language'; +import { siteLanguageListSelector, siteLanguageOptionsSelector } from '../site-language'; export const storeName = 'accountSettings'; @@ -195,11 +194,14 @@ export const coachingConsentPageSelector = createSelector( export const demographicsSectionSelector = createSelector( formValuesSelector, draftsSelector, + errorSelector, ( formValues, drafts, + errors, ) => ({ formValues, drafts, + formErrors: errors, }), ); diff --git a/src/account-settings/demographics/Checkboxes.jsx b/src/account-settings/demographics/Checkboxes.jsx index 4a037f1..d593404 100644 --- a/src/account-settings/demographics/Checkboxes.jsx +++ b/src/account-settings/demographics/Checkboxes.jsx @@ -29,7 +29,7 @@ export const Checkboxes = (props) => { setSelected(newSelected); } - // If unchecked, make sure this option is NOT in `seleted` + // If unchecked, make sure this option is NOT in `selected` if (!value) { setSelected(selected.filter(i => i !== option)); } diff --git a/src/account-settings/demographics/DemographicsSection.jsx b/src/account-settings/demographics/DemographicsSection.jsx index be6167f..b1c3543 100644 --- a/src/account-settings/demographics/DemographicsSection.jsx +++ b/src/account-settings/demographics/DemographicsSection.jsx @@ -1,31 +1,46 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Input } from '@edx/paragon'; -import memoize from 'memoize-one'; - -import { demographicsSectionSelector } from '../data/selectors'; -import { saveMultipleSettings, updateDraft } from '../data/actions'; -import EditableField from '../EditableField'; -import Checkboxes from './Checkboxes'; -import messages from './DemographicsSection.messages'; import { - SELF_DESCRIBE, - DEMOGRAPHICS_GENDER_OPTIONS, + DECLINED, + DEMOGRAPHICS_EDUCATION_LEVEL_OPTIONS, DEMOGRAPHICS_ETHNICITY_OPTIONS, + DEMOGRAPHICS_GENDER_OPTIONS, DEMOGRAPHICS_INCOME_OPTIONS, DEMOGRAPHICS_MILITARY_HISTORY_OPTIONS, - DEMOGRAPHICS_EDUCATION_LEVEL_OPTIONS, - OTHER, - DEMOGRAPHICS_WORK_STATUS_OPTIONS, DEMOGRAPHICS_WORK_SECTOR_OPTIONS, - DECLINED, + DEMOGRAPHICS_WORK_STATUS_OPTIONS, + OTHER, + SELF_DESCRIBE, } from '../data/constants'; +import { + FormattedMessage, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; +import { saveMultipleSettings, updateDraft } from '../data/actions'; + +import Alert from '../Alert'; +import Checkboxes from './Checkboxes'; +import EditableField from '../EditableField'; +import { Input } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { demographicsSectionSelector } from '../data/selectors'; +import get from 'lodash.get'; +import isEmpty from 'lodash.isempty'; +import memoize from 'memoize-one'; +import messages from './DemographicsSection.messages'; class DemographicsSection extends React.Component { constructor(props, context) { - super(props, context) + super(props, context); + + this.alertRef = React.createRef(); + } + + componentDidUpdate() { + if(!isEmpty(this.props.formErrors)) { + this.alertRef.current.focus(); + } } getLocalizedOptions = memoize((locale) => ({ @@ -67,13 +82,15 @@ class DemographicsSection extends React.Component { } ethnicityFieldDisplay = () => { - const ethnicities = this.props.formValues.demographics_user_ethnicity; - return ethnicities.map((e) => { - if (e == DECLINED) { - return this.props.intl.formatMessage(messages[`account.settings.field.demographics.options.declined`]); - } - return this.props.intl.formatMessage(messages[`account.settings.field.demographics.ethnicity.options.${e}`]) - }).join(", ") + if (get(this, 'props.formValues.demographics_user_ethnicity')) { + const ethnicities = this.props.formValues.demographics_user_ethnicity; + return ethnicities.map((e) => { + if (e == DECLINED) { + return this.props.intl.formatMessage(messages[`account.settings.field.demographics.options.declined`]); + } + return this.props.intl.formatMessage(messages[`account.settings.field.demographics.ethnicity.options.${e}`]); + }).join(", ") + } } handleEditableFieldChange = (name, value) => { @@ -95,6 +112,31 @@ class DemographicsSection extends React.Component { this.props.saveMultipleSettings(settingsArray, formId); }; + /** + * If an error is encountered when trying to communicate with the Demographics IDA then we will + * display an Alert letting the user know that their info will not be retrieved or displayed + * and temporarily cannot be updated. + */ + renderDemographicsServiceIssueWarning() { + if (!isEmpty(this.props.formErrors)) { + return ( +
+ + + +
+ ); + } else { + return null; + } + } + render() { const editableFieldProps = { onChange: this.handleEditableFieldChange, @@ -119,6 +161,7 @@ class DemographicsSection extends React.Component {

{this.props.intl.formatMessage(messages['account.settings.section.demographics.information'])}

+ {this.renderDemographicsServiceIssueWarning()} ) } -} +}; DemographicsSection.propTypes = { intl: intlShape.isRequired, @@ -254,10 +297,13 @@ DemographicsSection.propTypes = { demographics_current_work_sector: PropTypes.string, demographics_future_work_sector: PropTypes.string, }).isRequired, + formErrors: PropTypes.shape({ + demographicsError: PropTypes.string, + }).isRequired, updateDraft: PropTypes.func.isRequired }; export default connect(demographicsSectionSelector, { saveMultipleSettings, updateDraft, -})(injectIntl(DemographicsSection)) +})(injectIntl(DemographicsSection)); diff --git a/src/account-settings/demographics/data/service.js b/src/account-settings/demographics/data/service.js index 69ed3e0..c2f1dd4 100644 --- a/src/account-settings/demographics/data/service.js +++ b/src/account-settings/demographics/data/service.js @@ -2,6 +2,23 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getConfig } from '@edx/frontend-platform'; import { convertData, TO, FROM } from './utils'; +/** + * If there was an error making the PATCH call then we create an apiError object containing a + * 'demographicsError' fieldError. The content of the error itself isn't particularly important. + * This will trigger the `renderDemographicsServiceIssueWarningMessage()` (in + * DemographicsSection.jsx) to display an Alert to let the end-user know that there may be an + * issue communicating with the Demographics service. + * + * @param {Error} error + */ +export function createDemographicsError(error) { + const apiError = Object.create(error); + apiError.fieldErrors = { + demographicsError: error.customAttributes.httpErrorType, + }; + return apiError; +} + /** * post all of the data related to demographics. * @param {Number} userId users are identified in the api by LMS id @@ -15,7 +32,7 @@ export async function postDemographics(userId) { ({ data } = await getAuthenticatedHttpClient() .post(requestUrl, commitValues) .catch((error) => { - const apiError = Object.create(error); + const apiError = createDemographicsError(error); throw apiError; })); @@ -36,7 +53,29 @@ export async function getDemographics(userId) { data = convertData(data, FROM); } catch (error) { - data = await postDemographics(userId); + const apiError = Object.create(error); + // if the API called resulted in this user receiving a 404 then follow up with a POST call to + // try and create the demographics entity on the backend + if (apiError.customAttributes.httpErrorStatus) { + if (apiError.customAttributes.httpErrorStatus === 404) { + data = await postDemographics(userId); + } + } else { + data = { + user: userId, + demographics_gender: '', + demographics_gender_description: '', + demographics_income: '', + demographics_learner_education_level: '', + demographics_parent_education_level: '', + demographics_military_history: '', + demographics_work_status: '', + demographics_work_status_description: '', + demographics_current_work_sector: '', + demographics_future_work_sector: '', + demographics_user_ethnicity: [], + }; + } } return data; @@ -55,7 +94,7 @@ export async function patchDemographics(userId, commitValues) { ({ data } = await getAuthenticatedHttpClient() .patch(requestUrl, convertedCommitValues) .catch((error) => { - const apiError = Object.create(error); + const apiError = createDemographicsError(error); throw apiError; })); diff --git a/src/account-settings/demographics/test/DemographicsSection.test.jsx b/src/account-settings/demographics/test/DemographicsSection.test.jsx new file mode 100644 index 0000000..bf99236 --- /dev/null +++ b/src/account-settings/demographics/test/DemographicsSection.test.jsx @@ -0,0 +1,128 @@ +import * as auth from '@edx/frontend-platform/auth'; +import * as selectors from '../../data/selectors'; + +import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; + +import DemographicsSection from '../DemographicsSection'; +import { Provider } from 'react-redux'; +import React from 'react'; +import configureStore from 'redux-mock-store'; +import renderer from 'react-test-renderer'; + +jest.mock('@edx/frontend-platform/auth'); + +const IntlDemographicsSection = injectIntl(DemographicsSection); + +jest.mock('../../data/selectors', () => { + return jest.fn().mockImplementation(() => ({ demographicsSectionSelector: () => ({}) })); +}); + +const mockStore = configureStore(); + +describe('DemographicsSection', () => { + let props = {}; + let store = {}; + + const reduxWrapper = children => ( + + {children} + + ); + + beforeEach(() => { + store = mockStore(); + props = { + updateDraft: jest.fn(), + formValues: { + demographics_gender: 'declined', + demographics_gender_description: '', + demographics_user_ethnicity: [], + demographics_income: 'declined', + demographics_military_history: 'declined', + demographics_learner_education_level: 'declined', + demographics_parent_education_level: 'declined', + demographics_work_status: 'declined', + demographics_work_status_description: '', + demographics_current_work_sector: 'declined', + demographics_future_work_sector: 'declined', + demographics_user: 1, + }, + formErrors: {}, + intl: {}, + }; + auth.getAuthenticatedHttpClient = jest.fn(() => ({ + patch: async () => ({ + data: { status: 200 }, + catch: () => {}, + }), + })); + auth.getAuthenticatedUser = jest.fn(() => ({ userId: 1 })); + }); + + it('should render', () => { + const wrapper = renderer.create(reduxWrapper()).toJSON(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render an Alert if an error occurs', () => { + props = { + ...props, + formErrors: { + demographicsError: "api-error" + } + }; + + const wrapper = renderer.create(reduxWrapper()).toJSON(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should set user input correctly when user provides gender self-description', () => { + props = { + ...props, + formValues: { + demographics_gender: 'self-describe', + demographics_gender_description: 'test', + }, + }; + + const wrapper = renderer.create(reduxWrapper()).toJSON(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should set user input correctly when user provides answers to work_status question', () => { + props = { + ...props, + formValues: { + demographics_work_status: 'other', + demographics_work_status_description: 'test', + } + } + + const wrapper = renderer.create(reduxWrapper()).toJSON(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render ethnicity text correctly', () => { + props = { + ...props, + formValues: { + demographics_user_ethnicity: ['asian'] + } + } + + const wrapper = renderer.create(reduxWrapper()).toJSON(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render ethnicity correctly when multiple options are selected', () => { + props = { + ...props, + formValues: { + demographics_user_ethnicity: ['hispanic-latin-spanish', 'white'] + } + } + + const wrapper = renderer.create(reduxWrapper()).toJSON(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/account-settings/demographics/test/__snapshots__/DemographicsSection.test.jsx.snap b/src/account-settings/demographics/test/__snapshots__/DemographicsSection.test.jsx.snap new file mode 100644 index 0000000..9e2e422 --- /dev/null +++ b/src/account-settings/demographics/test/__snapshots__/DemographicsSection.test.jsx.snap @@ -0,0 +1,3713 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DemographicsSection should render 1`] = ` +
+

+ Optional Information +

+
+
+
+
+
+ Gender identity +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Race/Ethnicity identity +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Family income +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ U.S. Military status +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Your education level +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Parents/Guardians education level +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Employment status +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Current work industry +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Future work industry +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+`; + +exports[`DemographicsSection should render an Alert if an error occurs 1`] = ` +
+

+ Optional Information +

+
+
+
+
+ + An error occurred attempting to retrieve or save your account information. Please try again later. + +
+
+
+
+
+
+
+
+ Gender identity +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Race/Ethnicity identity +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Family income +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ U.S. Military status +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Your education level +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Parents/Guardians education level +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Employment status +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Current work industry +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+
+
+
+
+ Future work industry +
+ +
+

+ Prefer not to respond +

+

+

+
+
+
+`; + +exports[`DemographicsSection should render ethnicity correctly when multiple options are selected 1`] = ` +
+

+ Optional Information +

+
+
+
+
+
+ Gender identity +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Race/Ethnicity identity +
+ +
+

+ Hispanic, Latin, or Spanish origin, White +

+

+

+
+
+
+
+
+
+
+ Family income +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ U.S. Military status +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Your education level +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Parents/Guardians education level +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Employment status +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Current work industry +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Future work industry +
+ +
+

+ +

+

+

+
+
+
+`; + +exports[`DemographicsSection should render ethnicity text correctly 1`] = ` +
+

+ Optional Information +

+
+
+
+
+
+ Gender identity +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Race/Ethnicity identity +
+ +
+

+ Asian +

+

+

+
+
+
+
+
+
+
+ Family income +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ U.S. Military status +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Your education level +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Parents/Guardians education level +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Employment status +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Current work industry +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Future work industry +
+ +
+

+ +

+

+

+
+
+
+`; + +exports[`DemographicsSection should set user input correctly when user provides answers to work_status question 1`] = ` +
+

+ Optional Information +

+
+
+
+
+
+ Gender identity +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Race/Ethnicity identity +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Family income +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ U.S. Military status +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Your education level +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Parents/Guardians education level +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Employment status +
+ +
+

+ Other: test +

+

+

+
+
+
+
+
+
+
+ Current work industry +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Future work industry +
+ +
+

+ +

+

+

+
+
+
+`; + +exports[`DemographicsSection should set user input correctly when user provides gender self-description 1`] = ` +
+

+ Optional Information +

+
+
+
+
+
+ Gender identity +
+ +
+

+ Prefer to self-describe: test +

+

+

+
+
+
+
+
+
+
+ Race/Ethnicity identity +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Family income +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ U.S. Military status +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Your education level +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Parents/Guardians education level +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Employment status +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Current work industry +
+ +
+

+ +

+

+

+
+
+
+
+
+
+
+ Future work industry +
+ +
+

+ +

+

+

+
+
+
+`;