diff --git a/.env b/.env index 34dbdf6..b8a67f6 100644 --- a/.env +++ b/.env @@ -5,6 +5,7 @@ CSRF_TOKEN_API_PATH=null ECOMMERCE_BASE_URL=null LANGUAGE_PREFERENCE_COOKIE_NAME=null LMS_BASE_URL=null +DEMOGRAPHICS_BASE_URL=null LOGIN_URL=null LOGOUT_URL=null MARKETING_SITE_BASE_URL=null diff --git a/.env.development b/.env.development index 072c5fd..df652a1 100644 --- a/.env.development +++ b/.env.development @@ -8,6 +8,7 @@ LMS_BASE_URL='http://localhost:18000' LOGIN_URL='http://localhost:18000/login' LOGOUT_URL='http://localhost:18000/logout' MARKETING_SITE_BASE_URL='http://localhost:18000' +DEMOGRAPHICS_BASE_URL='http://localhost:18360' NODE_ENV='development' ORDER_HISTORY_URL='localhost:1996/orders' PORT=1997 @@ -18,3 +19,4 @@ SUPPORT_URL='http://localhost:18000/support' USER_INFO_COOKIE_NAME='edx-user-info' # Temporary, Remove this once we are ready to release the feature. COACHING_ENABLED=true +ENABLE_DEMOGRAPHICS_COLLECTION=true diff --git a/.env.test b/.env.test index 155b7bf..f82034f 100644 --- a/.env.test +++ b/.env.test @@ -8,6 +8,7 @@ LMS_BASE_URL='http://localhost:18000' LOGIN_URL='http://localhost:18000/login' LOGOUT_URL='http://localhost:18000/logout' MARKETING_SITE_BASE_URL='http://localhost:18000' +DEMOGRAPHICS_BASE_URL='http://localhost:18360' NODE_ENV=null ORDER_HISTORY_URL='localhost:1996/orders' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' @@ -16,3 +17,4 @@ SITE_NAME='edX' SUPPORT_URL='http://localhost:18000/support' USER_INFO_COOKIE_NAME='edx-user-info' COACHING_ENABLED='' +ENABLE_DEMOGRAPHICS_COLLECTION='' diff --git a/package-lock.json b/package-lock.json index a67d297..587ebf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11646,6 +11646,11 @@ "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", "integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=" }, + "lodash.pickby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", + "integrity": "sha1-feoh2MGNdwOifHBMFdO4SmfjOv8=" + }, "lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", diff --git a/package.json b/package.json index b45c4ab..7c45cf1 100755 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "lodash.merge": "4.6.2", "lodash.omit": "4.5.0", "lodash.pick": "4.4.0", + "lodash.pickby": "^4.6.0", "lodash.snakecase": "4.1.1", "memoize-one": "5.1.1", "newrelic": "5.13.1", diff --git a/src/account-settings/AccountSettingsPage.jsx b/src/account-settings/AccountSettingsPage.jsx index 9a14da3..7365b9a 100644 --- a/src/account-settings/AccountSettingsPage.jsx +++ b/src/account-settings/AccountSettingsPage.jsx @@ -36,6 +36,7 @@ import { } from './data/constants'; import { fetchSiteLanguages } from './site-language'; import CoachingToggle from './coaching/CoachingToggle'; +import DemographicsSection from './demographics/DemographicsSection'; class AccountSettingsPage extends React.Component { constructor(props, context) { @@ -363,7 +364,9 @@ class AccountSettingsPage extends React.Component { /> } - + {getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && + + }

{this.props.intl.formatMessage(messages['account.settings.section.social.media'])} diff --git a/src/account-settings/AccountSettingsPage.messages.jsx b/src/account-settings/AccountSettingsPage.messages.jsx index 20e40ba..6c5535d 100644 --- a/src/account-settings/AccountSettingsPage.messages.jsx +++ b/src/account-settings/AccountSettingsPage.messages.jsx @@ -46,6 +46,11 @@ const messages = defineMessages({ defaultMessage: 'Profile Information', description: 'The profile information section heading.', }, + 'account.settings.section.demographics.information': { + id: 'account.settings.section.demographics.information', + defaultMessage: 'Optional Information', + description: 'The optional information section heading.', + }, 'account.settings.section.site.preferences': { id: 'account.settings.section.site.preferences', defaultMessage: 'Site Preferences', @@ -287,6 +292,7 @@ const messages = defineMessages({ defaultMessage: 'Select a Language', description: 'Option for an empty value on account settings spoken languages field.', }, + 'account.settings.field.time.zone': { id: 'account.settings.field.time.zone', defaultMessage: 'Time zone', diff --git a/src/account-settings/EditableField.jsx b/src/account-settings/EditableField.jsx index cdb57c6..a14d873 100644 --- a/src/account-settings/EditableField.jsx +++ b/src/account-settings/EditableField.jsx @@ -23,6 +23,7 @@ function EditableField(props) { emptyLabel, type, value, + userSuppliedValue, options, saveState, error, @@ -66,15 +67,22 @@ function EditableField(props) { const renderValue = (rawValue) => { if (!rawValue) return renderEmptyLabel(); + let value = rawValue; if (options) { // Use == instead of === to prevent issues when HTML casts numbers as strings // eslint-disable-next-line eqeqeq const selectedOption = options.find(option => option.value == rawValue); - if (selectedOption) return selectedOption.label; + if (selectedOption) { + value = selectedOption.label; + }; } - return rawValue; + if (userSuppliedValue) { + value += `: ${userSuppliedValue}`; + } + + return value; }; const renderConfirmationMessage = () => { @@ -106,6 +114,7 @@ function EditableField(props) { options={options} {...others} /> + <>{others.children}

+

  • + + {intl.formatMessage(messages['account.settings.section.demographics.information'])} + +
  • {intl.formatMessage(messages['account.settings.section.social.media'])} diff --git a/src/account-settings/data/actions.js b/src/account-settings/data/actions.js index 1ae2f22..8f43a22 100644 --- a/src/account-settings/data/actions.js +++ b/src/account-settings/data/actions.js @@ -100,9 +100,9 @@ export const savePreviousSiteLanguage = previousSiteLanguage => ({ payload: { previousSiteLanguage }, }); -export const saveMultipleSettings = settingsArray => ({ +export const saveMultipleSettings = (settingsArray, form = null) => ({ type: SAVE_MULTIPLE_SETTINGS.BASE, - payload: { settingsArray }, + payload: { settingsArray, form }, }); export const saveMultipleSettingsBegin = () => ({ diff --git a/src/account-settings/data/constants.js b/src/account-settings/data/constants.js index 0ac9ed7..a647bec 100644 --- a/src/account-settings/data/constants.js +++ b/src/account-settings/data/constants.js @@ -112,3 +112,92 @@ const COUNTRY_STATES_MAP = { export function getStatesList(country) { return country && COUNTRY_STATES_MAP[country.toUpperCase()]; } + +export const SELF_DESCRIBE = 'self-describe'; +export const DEMOGRAPHICS_GENDER_OPTIONS = [ + '', + 'woman', + 'man', + 'non-binary', + SELF_DESCRIBE, +]; + +export const OTHER = 'other'; +export const DEMOGRAPHICS_ETHNICITY_OPTIONS = [ + 'american-indian-or-alaska-native', + 'asian', + 'black-or-african-american', + 'hispanic-latin-spanish', + 'middle-eastern-or-north-african', + 'native-hawaiian-or-pacific-islander', + 'white', + OTHER, +]; + +export const DEMOGRAPHICS_INCOME_OPTIONS = [ + '', + 'less-than-10k', + '10k-25k', + '25k-50k', + '50k-75k', + 'over-100k', + 'unsure', +]; + +export const DEMOGRAPHICS_MILITARY_HISTORY_OPTIONS = [ + '', + 'never-served', + 'training', + 'active', + 'previously-active', +]; + +export const DEMOGRAPHICS_EDUCATION_LEVEL_OPTIONS = [ + '', + 'no-high-school', + 'some-high-school', + 'high-school-ged-equivalent', + 'some-college', + 'associates', + 'bachelors', + 'masters', + 'professional', + 'doctorate', +]; + +export const DEMOGRAPHICS_WORK_STATUS_OPTIONS = [ + '', + 'full-time', + 'part-time', + 'not-employed-looking', + 'not-employed-not-looking', + 'unable', + 'retired', + 'other', +]; + +export const DEMOGRAPHICS_WORK_SECTOR_OPTIONS = [ + '', + 'accommodation-food', + 'administrative-support-waste-remediation', + 'agriculture-forestry-fishing-hunting', + 'arts-entertainment-recreation', + 'construction', + 'educational', + 'finance-insurance', + 'healthcare-social', + 'information', + 'management', + 'manufacturing', + 'mining-quarry-oil-gas', + 'professional-scientific-technical', + 'public-admin', + 'real-estate', + 'retail', + 'transport-warehousing', + 'utilities', + 'trade', + 'other', +]; + +export const DECLINED = 'declined'; diff --git a/src/account-settings/data/sagas.js b/src/account-settings/data/sagas.js index 2b5e553..3ab3ca9 100644 --- a/src/account-settings/data/sagas.js +++ b/src/account-settings/data/sagas.js @@ -106,11 +106,11 @@ export function* handleSaveSettings(action) { // handles mutiple settings saved at once, in order, and stops executing on first failure. -export function* handleSaveMultipleSettings(settings) { +export function* handleSaveMultipleSettings(action) { try { yield put(saveMultipleSettingsBegin()); const { username, userId } = getAuthenticatedUser(); - const { settingsArray } = settings.payload; + const { settingsArray, form } = action.payload; for (let i = 0; i < settingsArray.length; i += 1) { const { formId, commitValues } = settingsArray[i]; yield put(saveSettingsBegin()); @@ -118,7 +118,11 @@ export function* handleSaveMultipleSettings(settings) { const savedSettings = yield call(patchSettings, username, commitData, userId); yield put(saveSettingsSuccess(savedSettings, commitData)); } - yield put(saveMultipleSettingsSuccess(settings)); + yield put(saveMultipleSettingsSuccess(action)); + if (form) { + yield delay(1000); + yield put(closeForm(form)); + } } catch (e) { if (e.fieldErrors) { yield put(saveMultipleSettingsFailure({ fieldErrors: e.fieldErrors })); diff --git a/src/account-settings/data/selectors.js b/src/account-settings/data/selectors.js index f2289ff..d441dd2 100644 --- a/src/account-settings/data/selectors.js +++ b/src/account-settings/data/selectors.js @@ -191,3 +191,15 @@ export const coachingConsentPageSelector = createSelector( formErrors: errors, }), ); + +export const demographicsSectionSelector = createSelector( + formValuesSelector, + draftsSelector, + ( + formValues, + drafts, + ) => ({ + formValues, + drafts, + }), +); diff --git a/src/account-settings/data/service.js b/src/account-settings/data/service.js index c265bf0..683d50d 100644 --- a/src/account-settings/data/service.js +++ b/src/account-settings/data/service.js @@ -1,12 +1,14 @@ import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import pick from 'lodash.pick'; +import pickBy from 'lodash.pickby'; import omit from 'lodash.omit'; import isEmpty from 'lodash.isempty'; import { handleRequestError, unpackFieldErrors } from './utils'; import { getThirdPartyAuthProviders } from '../third-party-auth'; import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service'; +import { getDemographics, patchDemographics } from '../demographics/data/service'; const SOCIAL_PLATFORMS = [ { id: 'twitter', key: 'social_link_twitter' }, @@ -161,6 +163,7 @@ export async function getSettings(username, userRoles, userId) { getProfileDataManager(username, userRoles), getTimeZones(), getConfig().COACHING_ENABLED && getCoachingPreferences(userId), + getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographics(userId), ]); return { @@ -170,6 +173,7 @@ export async function getSettings(username, userRoles, userId) { profileDataManager: results[3], timeZones: results[4], coaching: results[5], + ...results[6], // demographics }; } @@ -183,9 +187,11 @@ export async function patchSettings(username, commitValues, userId) { // user/v1/preferences where it does update. This is the one we use. const preferenceKeys = ['time_zone']; const coachingKeys = ['coaching']; + const isDemographicsKey = (value, key) => key.includes('demographics'); const accountCommitValues = omit(commitValues, preferenceKeys, coachingKeys); const preferenceCommitValues = pick(commitValues, preferenceKeys); const coachingCommitValues = pick(commitValues, coachingKeys); + const demographicsCommitValues = pickBy(commitValues, isDemographicsKey); const patchRequests = []; if (!isEmpty(accountCommitValues)) { @@ -197,6 +203,9 @@ export async function patchSettings(username, commitValues, userId) { if (!isEmpty(coachingCommitValues)) { patchRequests.push(patchCoachingPreferences(userId, coachingCommitValues)); } + if (!isEmpty(demographicsCommitValues)) { + patchRequests.push(patchDemographics(userId, demographicsCommitValues)); + } const results = await Promise.all(patchRequests); // Assigns in order of requests. Preference keys diff --git a/src/account-settings/demographics/Checkboxes.jsx b/src/account-settings/demographics/Checkboxes.jsx new file mode 100644 index 0000000..4a037f1 --- /dev/null +++ b/src/account-settings/demographics/Checkboxes.jsx @@ -0,0 +1,78 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { CheckBox } from '@edx/paragon'; +import { DECLINED } from '../data/constants'; + +export const Checkboxes = (props) => { + const { + id, + options, + values, + onChange, + } = props; + + const [selected, setSelected] = useState(values); + useEffect(() => { + onChange(id, selected) + }, [selected]) + + const handleToggle = (value, option) => { + // If the user checked 'declined', uncheck all other options + if (value && option == DECLINED) { + setSelected([DECLINED]); + return; + } + + // If option checked, make sure this option is in `selected` (and remove 'declined') + if (value && !selected.includes(option)) { + const newSelected = selected.filter(i => i !== DECLINED).concat(option); + setSelected(newSelected); + } + + // If unchecked, make sure this option is NOT in `seleted` + if (!value) { + setSelected(selected.filter(i => i !== option)); + } + } + + const renderCheckboxes = () => { + return options.map((option, index) => { + const isFirst = index == 0; + const isChecked = selected.includes(option.value); + return ( +
    + handleToggle(value, option.value)} + /> +
    + ) + }) + } + + return ( +
    + {renderCheckboxes()} +
    + ) +} + +Checkboxes.propTypes = { + id: PropTypes.string, + options: PropTypes.array, + values: PropTypes.array, + onChange: PropTypes.func, +}; + +Checkboxes.defaultProps = { + options: [], + values: [], +} + +export default Checkboxes; diff --git a/src/account-settings/demographics/DemographicsSection.jsx b/src/account-settings/demographics/DemographicsSection.jsx new file mode 100644 index 0000000..be6167f --- /dev/null +++ b/src/account-settings/demographics/DemographicsSection.jsx @@ -0,0 +1,263 @@ +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, + DEMOGRAPHICS_ETHNICITY_OPTIONS, + DEMOGRAPHICS_INCOME_OPTIONS, + DEMOGRAPHICS_MILITARY_HISTORY_OPTIONS, + DEMOGRAPHICS_EDUCATION_LEVEL_OPTIONS, + OTHER, + DEMOGRAPHICS_WORK_STATUS_OPTIONS, + DEMOGRAPHICS_WORK_SECTOR_OPTIONS, + DECLINED, +} from '../data/constants'; + +class DemographicsSection extends React.Component { + constructor(props, context) { + super(props, context) + } + + getLocalizedOptions = memoize((locale) => ({ + demographicsGenderOptions: DEMOGRAPHICS_GENDER_OPTIONS.map(key => ({ + value: key, + label: this.props.intl.formatMessage(messages[`account.settings.field.demographics.gender.options.${key || 'empty'}`]), + })).concat(this.getDeclinedOption()), + demographicsEthnicityOptions: DEMOGRAPHICS_ETHNICITY_OPTIONS.map(key => ({ + value: key, + label: this.props.intl.formatMessage(messages[`account.settings.field.demographics.ethnicity.options.${key || 'empty'}`]), + })).concat(this.getDeclinedOption()), + demographicsIncomeOptions: DEMOGRAPHICS_INCOME_OPTIONS.map(key => ({ + value: key, + label: this.props.intl.formatMessage(messages[`account.settings.field.demographics.income.options.${key || 'empty'}`]), + })).concat(this.getDeclinedOption()), + demographicsMilitaryHistoryOptions: DEMOGRAPHICS_MILITARY_HISTORY_OPTIONS.map(key => ({ + value: key, + label: this.props.intl.formatMessage(messages[`account.settings.field.demographics.military_history.options.${key || 'empty'}`]), + })).concat(this.getDeclinedOption()), + demographicsEducationLevelOptions: DEMOGRAPHICS_EDUCATION_LEVEL_OPTIONS.map(key => ({ + value: key, + label: this.props.intl.formatMessage(messages[`account.settings.field.demographics.education_level.options.${key || 'empty'}`]), + })).concat(this.getDeclinedOption()), + demographicsWorkStatusOptions: DEMOGRAPHICS_WORK_STATUS_OPTIONS.map(key => ({ + value: key, + label: this.props.intl.formatMessage(messages[`account.settings.field.demographics.work_status.options.${key || 'empty'}`]), + })).concat(this.getDeclinedOption()), + demographicsWorkSectorOptions: DEMOGRAPHICS_WORK_SECTOR_OPTIONS.map(key => ({ + value: key, + label: this.props.intl.formatMessage(messages[`account.settings.field.demographics.work_sector.options.${key || 'empty'}`]), + })).concat(this.getDeclinedOption()), + })); + + getDeclinedOption() { + return [{ + value: DECLINED, + label: this.props.intl.formatMessage(messages[`account.settings.field.demographics.options.declined`]) + }] + } + + 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(", ") + } + + handleEditableFieldChange = (name, value) => { + this.props.updateDraft(name, value); + }; + + handleSubmit = (formId, values) => { + // We have some custom fields in this section. Instead of relying on the + // submitted values, submit the values stored in 'drafts'. + const drafts = this.props.drafts; + const settingsArray = [] + for (let field in drafts) { + settingsArray.push({ + formId: field, + commitValues: drafts[field] + }) + } + + this.props.saveMultipleSettings(settingsArray, formId); + }; + + render() { + const editableFieldProps = { + onChange: this.handleEditableFieldChange, + onSubmit: this.handleSubmit, + }; + + const { + demographicsGenderOptions, + demographicsEthnicityOptions, + demographicsIncomeOptions, + demographicsMilitaryHistoryOptions, + demographicsEducationLevelOptions, + demographicsWorkStatusOptions, + demographicsWorkSectorOptions, + } = this.getLocalizedOptions(this.context.locale); + + const showSelfDescribe = this.props.formValues.demographics_gender == SELF_DESCRIBE; + const showWorkStatusDescribe = this.props.formValues.demographics_work_status == OTHER; + + return ( +
    +

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

    + + + {showSelfDescribe && + this.handleEditableFieldChange(`demographics_gender_description`, e.target.value)} + aria-label={this.props.intl.formatMessage(messages['account.settings.field.demographics.gender_description'])} + className="mt-1" + /> + } + + + + + + + + {showWorkStatusDescribe && + this.handleEditableFieldChange(`demographics_work_status_description`, e.target.value)} + aria-label={this.props.intl.formatMessage(messages['account.settings.field.demographics.work_status_description'])} + className="mt-1" + /> + } + + + +
    + ) + } +} + +DemographicsSection.propTypes = { + intl: intlShape.isRequired, + formValues: PropTypes.shape({ + demographics_gender: PropTypes.string, + demographics_user_ethnicity: PropTypes.array, + demographics_income: PropTypes.string, + demographics_military_history: PropTypes.string, + demographics_learner_education_level: PropTypes.string, + demographics_parent_education_level: PropTypes.string, + demographics_work_status: PropTypes.string, + demographics_current_work_sector: PropTypes.string, + demographics_future_work_sector: PropTypes.string, + }).isRequired, + updateDraft: PropTypes.func.isRequired +}; + +export default connect(demographicsSectionSelector, { + saveMultipleSettings, + updateDraft, +})(injectIntl(DemographicsSection)) diff --git a/src/account-settings/demographics/DemographicsSection.messages.jsx b/src/account-settings/demographics/DemographicsSection.messages.jsx new file mode 100644 index 0000000..622b3e7 --- /dev/null +++ b/src/account-settings/demographics/DemographicsSection.messages.jsx @@ -0,0 +1,456 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'account.settings.section.demographics.information': { + id: 'account.settings.section.demographics.information', + defaultMessage: 'Optional Information', + description: 'The optional information section heading.', + }, + 'account.settings.field.demographics.gender': { + id: 'account.settings.field.demographics.gender', + defaultMessage: 'Gender identity', + description: 'Label for account settings gender identity field.', + }, + 'account.settings.field.demographics.gender.empty': { + id: 'account.settings.field.demographics.gender.empty', + defaultMessage: 'Add gender identity', + description: 'Placeholder for empty account settings gender identity field.', + }, + 'account.settings.field.demographics.gender.options.empty': { + id: 'account.settings.field.demographics.gender.options.empty', + defaultMessage: 'Select a gender identity', + description: 'Placeholder for the gender identity options dropdown.', + }, + 'account.settings.field.demographics.gender.options.woman': { + id: 'account.settings.field.demographics.gender.options.woman', + defaultMessage: 'Woman', + description: 'The label for the woman gender identity option.', + }, + 'account.settings.field.demographics.gender.options.man': { + id: 'account.settings.field.demographics.gender.options.man', + defaultMessage: 'Man', + description: 'The label for the man gender identity option.', + }, + 'account.settings.field.demographics.gender.options.non-binary': { + id: 'account.settings.field.demographics.gender.options.non-binary', + defaultMessage: 'Non-binary', + description: 'The label for the non-binary gender identity option.', + }, + 'account.settings.field.demographics.gender.options.self-describe': { + id: 'account.settings.field.demographics.gender.options.self-describe', + defaultMessage: 'Prefer to self-describe', + description: 'The label for self-describe gender identity option.', + }, + 'account.settings.field.demographics.gender_description': { + id: 'account.settings.field.demographics.gender_description', + defaultMessage: 'Gender identity description', + description: 'Label for account settings gender identity description field.', + }, + 'account.settings.field.demographics.gender_description.empty': { + id: 'account.settings.field.demographics.gender_description.empty', + defaultMessage: 'Enter description', + description: 'Placeholder for empty account settings gender identity field.', + }, + 'account.settings.field.demographics.ethnicity': { + id: 'account.settings.field.demographics.ethnicity', + defaultMessage: 'Race/Ethnicity identity', + description: 'Label for account settings ethnic background field.', + }, + 'account.settings.field.demographics.ethnicity.empty': { + id: 'account.settings.field.demographics.ethnicity.empty', + defaultMessage: 'Add race/ethnicity identity', + description: 'Placeholder for empty account settings ethnic background field.', + }, + 'account.settings.field.demographics.ethnicity.options.empty': { + id: 'account.settings.field.demographics.ethnicity.options.empty', + defaultMessage: 'Select all that apply', // TODO: Is this the desired text? + description: 'Placeholder for the ethnic background options field.', + }, + 'account.settings.field.demographics.ethnicity.options.american-indian-or-alaska-native': { + id: 'account.settings.field.demographics.ethnicity.options.american-indian-or-alaska-native', + defaultMessage: 'American Indian or Alaska Native', + description: 'The label for the American Indian or Alaska Native ethnicity option.', + }, + 'account.settings.field.demographics.ethnicity.options.asian': { + id: 'account.settings.field.demographics.ethnicity.options.asian', + defaultMessage: 'Asian', + description: 'The label for the Asian ethnicity option.', + }, + 'account.settings.field.demographics.ethnicity.options.black-or-african-american': { + id: 'account.settings.field.demographics.ethnicity.options.black-or-african-american', + defaultMessage: 'Black or African American', + description: 'The label for the Black or African American ethnicity option.', + }, + 'account.settings.field.demographics.ethnicity.options.hispanic-latin-spanish': { + id: 'account.settings.field.demographics.ethnicity.options.hispanic-latin-spanish', + defaultMessage: 'Hispanic, Latin, or Spanish origin', + description: 'The label for the Hispanic, Latin, or Spanish origin ethnicity option.', + }, + 'account.settings.field.demographics.ethnicity.options.middle-eastern-or-north-african': { + id: 'account.settings.field.demographics.ethnicity.options.middle-eastern-or-north-african', + defaultMessage: 'Middle Eastern or North African', + description: 'The label for the Middle Eastern or North African ethnicity option.', + }, + 'account.settings.field.demographics.ethnicity.options.native-hawaiian-or-pacific-islander': { + id: 'account.settings.field.demographics.ethnicity.options.native-hawaiian-or-pacific-islander', + defaultMessage: 'Native Hawaiian or Other Pacific Islander', + description: 'The label for the Native Hawaiian or Other Pacific Islander ethnicity option.', + }, + 'account.settings.field.demographics.ethnicity.options.white': { + id: 'account.settings.field.demographics.ethnicity.options.white', + defaultMessage: 'White', + description: 'The label for the White ethnicity option.', + }, + 'account.settings.field.demographics.ethnicity.options.other': { + id: 'account.settings.field.demographics.ethnicity.options.other', + defaultMessage: 'Some other race, ethnicity, or origin', + description: 'The label for the Some other race, ethnicity, or origin ethnicity option.', + }, + 'account.settings.field.demographics.income': { + id: 'account.settings.field.demographics.income', + defaultMessage: 'Family income', + description: 'Label for account settings household income field.', + }, + 'account.settings.field.demographics.income.empty': { + id: 'account.settings.field.demographics.income.empty', + defaultMessage: 'Add family income', + description: 'Placeholder for empty account settings household income field.', + }, + 'account.settings.field.demographics.income.options.empty': { + id: 'account.settings.field.demographics.income.options.empty', + defaultMessage: 'Select a family income range', + description: 'Placeholder for the household income dropdown.', + }, + 'account.settings.field.demographics.income.options.less-than-10k': { + id: 'account.settings.field.demographics.income.options.less-than-10k', + defaultMessage: 'Less than US $10,000', + description: 'The label for the less than US $10,000 income option.', + }, + 'account.settings.field.demographics.income.options.10k-25k': { + id: 'account.settings.field.demographics.income.options.10k-25k', + defaultMessage: 'US $10,000 - $25,000', + description: 'The label for the US $10,000 - $25,000 income option.', + }, + 'account.settings.field.demographics.income.options.25k-50k': { + id: 'account.settings.field.demographics.income.options.25k-50k', + defaultMessage: 'US $25,000 - $50,000', + description: 'The label for the US $25,000 - $50,000 income option.', + }, + 'account.settings.field.demographics.income.options.50k-75k': { + id: 'account.settings.field.demographics.income.options.50k-75k', + defaultMessage: 'US $50,000 - $75,000', + description: 'The label for the US $50,000 - $75,000 income option.', + }, + 'account.settings.field.demographics.income.options.over-100k': { + id: 'account.settings.field.demographics.income.options.over-100k', + defaultMessage: 'Over US $100,000', + description: 'The label for the over US $100,000 income option.', + }, + 'account.settings.field.demographics.income.options.unsure': { + id: 'account.settings.field.demographics.income.options.unsure', + defaultMessage: 'I don\'t know', + description: 'The label for the I don\'t know income option.', + }, + 'account.settings.field.demographics.military_history': { + id: 'account.settings.field.demographics.military_history', + defaultMessage: 'U.S. Military status', + description: 'Label for account settings military history field.', + }, + 'account.settings.field.demographics.military_history.empty': { + id: 'account.settings.field.demographics.military_history.empty', + defaultMessage: 'Add military status', + description: 'Placeholder for empty account settings military history field.', + }, + 'account.settings.field.demographics.military_history.options.empty': { + id: 'account.settings.field.demographics.military_history.options.empty', + defaultMessage: 'Select military status', + description: 'Placeholder for the military history dropdown.', + }, + 'account.settings.field.demographics.military_history.options.never-served': { + id: 'account.settings.field.demographics.income.options.never-served', + defaultMessage: 'Never served in the military', + description: 'The label for the never served in the military military history option.', + }, + 'account.settings.field.demographics.military_history.options.training': { + id: 'account.settings.field.demographics.income.options.training', + defaultMessage: 'Only on active duty for training', + description: 'The label for the only on active duty for training military history option.', + }, + 'account.settings.field.demographics.military_history.options.active': { + id: 'account.settings.field.demographics.income.options.active', + defaultMessage: 'Now on active duty', + description: 'The label for the now on active duty military history option.', + }, + 'account.settings.field.demographics.military_history.options.previously-active': { + id: 'account.settings.field.demographics.income.options.previously-active', + defaultMessage: 'On active duty in the past, but not now', + description: 'The label for the on active duty in the past, but not now military history option.', + }, + 'account.settings.field.demographics.learner_education_level': { + id: 'account.settings.field.demographics.learner_education_level', + defaultMessage: 'Your education level', + description: 'Label for account settings learner education level field.', + }, + 'account.settings.field.demographics.learner_education_level.empty': { + id: 'account.settings.field.demographics.learner_education_level.empty', + defaultMessage: 'Add education level', + description: 'Placeholder for empty account settings learner education level field.', + }, + 'account.settings.field.demographics.parent_education_level': { + id: 'account.settings.field.demographics.parent_education_level', + defaultMessage: 'Parents/Guardians education level', + description: 'Label for account settings parent education level field.', + }, + 'account.settings.field.demographics.parent_education_level.empty': { + id: 'account.settings.field.demographics.parent_education_level.empty', + defaultMessage: 'Add education level', + description: 'Placeholder for empty account settings parent education level field.', + }, + 'account.settings.field.demographics.education_level.options.empty': { + id: 'account.settings.field.demographics.education_level.options.empty', + defaultMessage: 'Select education level', + description: 'Placeholder for the education level options dropdown.', + }, + 'account.settings.field.demographics.education_level.options.no-high-school': { + id: 'account.settings.field.demographics.education_level.options.no-high-school', + defaultMessage: 'No High School', + description: 'The label for the no high school education level option.', + }, + 'account.settings.field.demographics.education_level.options.some-high-school': { + id: 'account.settings.field.demographics.education_level.options.some-high-school', + defaultMessage: 'Some High School', + description: 'The label for the some high school education level option.', + }, + 'account.settings.field.demographics.education_level.options.high-school-ged-equivalent': { + id: 'account.settings.field.demographics.education_level.options.high-school-ged-equivalent', + defaultMessage: 'High School diploma, GED, or equivalent', + description: 'The label for the high school diploma, GED, or equivalent education level option.', + }, + 'account.settings.field.demographics.education_level.options.some-college': { + id: 'account.settings.field.demographics.education_level.options.some-college', + defaultMessage: 'Some college, but no degree', + description: 'The label for the some college, but no degree education level option.', + }, + 'account.settings.field.demographics.education_level.options.some-college': { + id: 'account.settings.field.demographics.education_level.options.some-college', + defaultMessage: 'Some college, but no degree', + description: 'The label for the some college, but no degree education level option.', + }, + 'account.settings.field.demographics.education_level.options.associates': { + id: 'account.settings.field.demographics.education_level.options.associates', + defaultMessage: 'Associates degree', + description: 'The label for the Associates degree education level option.', + }, + 'account.settings.field.demographics.education_level.options.bachelors': { + id: 'account.settings.field.demographics.education_level.options.bachelors', + defaultMessage: 'Bachelors degree', + description: 'The label for the Bachelors degree education level option.', + }, + 'account.settings.field.demographics.education_level.options.masters': { + id: 'account.settings.field.demographics.education_level.options.masters', + defaultMessage: 'Masters degree', + description: 'The label for the Masters degree education level option.', + }, + 'account.settings.field.demographics.education_level.options.professional': { + id: 'account.settings.field.demographics.education_level.options.professional', + defaultMessage: 'Professional degree', + description: 'The label for the Professional degree education level option.', + }, + 'account.settings.field.demographics.education_level.options.doctorate': { + id: 'account.settings.field.demographics.education_level.options.doctorate', + defaultMessage: 'Doctorate degree', + description: 'The label for the Doctorate degree education level option.', + }, + 'account.settings.field.demographics.work_status': { + id: 'account.settings.field.demographics.work_status', + defaultMessage: 'Employment status', + description: 'Label for account settings work status field.', + }, + 'account.settings.field.demographics.work_status.empty': { + id: 'account.settings.field.demographics.work_status.empty', + defaultMessage: 'Add employment status', + description: 'Placeholder for empty account settings work status field.', + }, + 'account.settings.field.demographics.work_status.options.empty': { + id: 'account.settings.field.demographics.work_status.options.empty', + defaultMessage: 'Select employment status', + description: 'Placeholder for the work status options dropdown.', + }, + 'account.settings.field.demographics.work_status.options.full-time': { + id: 'account.settings.field.demographics.work_status.options.full-time', + defaultMessage: 'Employed, working full-time', + description: 'The label for the employed, working full-time work status option.', + }, + 'account.settings.field.demographics.work_status.options.part-time': { + id: 'account.settings.field.demographics.work_status.options.part-time', + defaultMessage: 'Employed, working part-time', + description: 'The label for the employed, working part-time work status option.', + }, + 'account.settings.field.demographics.work_status.options.not-employed-looking': { + id: 'account.settings.field.demographics.work_status.options.not-employed-looking', + defaultMessage: 'Not employed, looking for work', + description: 'The label for the not employed, looking for work work status option.', + }, + 'account.settings.field.demographics.work_status.options.not-employed-not-looking': { + id: 'account.settings.field.demographics.work_status.options.not-employed-not-looking', + defaultMessage: 'Not employed, not looking for work', + description: 'The label for the not employed, not looking for work work status option.', + }, + 'account.settings.field.demographics.work_status.options.unable': { + id: 'account.settings.field.demographics.work_status.options.unable', + defaultMessage: 'Unable to work', + description: 'The label for the unable to work work status option.', + }, + 'account.settings.field.demographics.work_status.options.retired': { + id: 'account.settings.field.demographics.work_status.options.retired', + defaultMessage: 'Retired', + description: 'The label for the retired work status option.', + }, + 'account.settings.field.demographics.work_status.options.other': { + id: 'account.settings.field.demographics.work_status.options.other', + defaultMessage: 'Other', + description: 'The label for the other work status option.', + }, + 'account.settings.field.demographics.work_status_description': { + id: 'account.settings.field.demographics.work_status_description', + defaultMessage: 'Employment status description', + description: 'Label for account settings work status description field.', + }, + 'account.settings.field.demographics.work_status_description.empty': { + id: 'account.settings.field.demographics.work_status_description.empty', + defaultMessage: 'Enter description', + description: 'Placeholder for empty account settings work status description field.', + }, + 'account.settings.field.demographics.current_work_sector': { + id: 'account.settings.field.demographics.current_work_sector', + defaultMessage: 'Current work industry', + description: 'Label for account settings current work sector field.', + }, + 'account.settings.field.demographics.current_work_sector.empty': { + id: 'account.settings.field.demographics.current_work_sector.empty', + defaultMessage: 'Add work industry', + description: 'Placeholder for empty account settings current work sector field.', + }, + 'account.settings.field.demographics.future_work_sector': { + id: 'account.settings.field.demographics.future_work_sector', + defaultMessage: 'Future work industry', + description: 'Label for account settings future work sector field.', + }, + 'account.settings.field.demographics.future_work_sector.empty': { + id: 'account.settings.field.demographics.future_work_sector.empty', + defaultMessage: 'Add work industry', + description: 'Placeholder for empty account settings future work sector field.', + }, + 'account.settings.field.demographics.work_sector.options.empty': { + id: 'account.settings.field.demographics.work_sector.options.empty', + defaultMessage: 'Select work industry', + description: 'Placeholder for the work sector options dropdown.', + }, + 'account.settings.field.demographics.work_sector.options.accommodation-food': { + id: 'account.settings.field.demographics.work_sector.options.accommodation-food', + defaultMessage: 'Accommodation and Food Services', + description: 'The label for the Accommodation and Food Services work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.administrative-support-waste-remediation': { + id: 'account.settings.field.demographics.work_sector.options.administrative-support-waste-remediation', + defaultMessage: 'Administrative and Support and Waste Management and Remediation Services', + description: 'The label for the Administrative and Support and Waste Management and Remediation Services work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.agriculture-forestry-fishing-hunting': { + id: 'account.settings.field.demographics.work_sector.options.agriculture-forestry-fishing-hunting', + defaultMessage: 'Agriculture, Forestry, Fishing and Hunting', + description: 'The label for the Agriculture, Forestry, Fishing and Hunting work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.arts-entertainment-recreation': { + id: 'account.settings.field.demographics.work_sector.options.arts-entertainment-recreation', + defaultMessage: 'Arts, Entertainment, and Recreation', + description: 'The label for the Arts, Entertainment, and Recreation work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.construction': { + id: 'account.settings.field.demographics.work_sector.options.construction', + defaultMessage: 'Construction', + description: 'The label for the Construction work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.educational': { + id: 'account.settings.field.demographics.work_sector.options.educational', + defaultMessage: 'Education Services', + description: 'The label for the Education Services work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.finance-insurance': { + id: 'account.settings.field.demographics.work_sector.options.finance-insurance', + defaultMessage: 'Finance and Insurance', + description: 'The label for the Finance and Insurance work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.healthcare-social': { + id: 'account.settings.field.demographics.work_sector.options.healthcare-social', + defaultMessage: 'Health Care and Social Assistance', + description: 'The label for the Health Care and Social Assistance work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.information': { + id: 'account.settings.field.demographics.work_sector.options.information', + defaultMessage: 'Information', + description: 'The label for the Information work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.management': { + id: 'account.settings.field.demographics.work_sector.options.management', + defaultMessage: 'Management of Companies and Enterprises', + description: 'The label for the Management of Companies and Enterprises work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.manufacturing': { + id: 'account.settings.field.demographics.work_sector.options.manufacturing', + defaultMessage: 'Manufacturing', + description: 'The label for the Manufacturing work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.mining-quarry-oil-gas': { + id: 'account.settings.field.demographics.work_sector.options.mining-quarry-oil-gas', + defaultMessage: 'Mining, Quarrying, and Oil and Gas Extraction', + description: 'The label for the Mining, Quarrying, and Oil and Gas Extraction work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.professional-scientific-technical': { + id: 'account.settings.field.demographics.work_sector.options.professional-scientific-technical', + defaultMessage: 'Professional, Scientific, and Technical Services', + description: 'The label for the Professional, Scientific, and Technical Services work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.public-admin': { + id: 'account.settings.field.demographics.work_sector.options.public-admin', + defaultMessage: 'Public Administration', + description: 'The label for the Public Administration work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.real-estate': { + id: 'account.settings.field.demographics.work_sector.options.real-estate', + defaultMessage: 'Real Estate and Rental and Leasing', + description: 'The label for the Real Estate and Rental and Leasing work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.retail': { + id: 'account.settings.field.demographics.work_sector.options.retail', + defaultMessage: 'Retail Trade', + description: 'The label for the Retail Trade work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.transport-warehousing': { + id: 'account.settings.field.demographics.work_sector.options.transport-warehousing', + defaultMessage: 'Transportation and Warehousing', + description: 'The label for the Transportation and Warehousing work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.utilities': { + id: 'account.settings.field.demographics.work_sector.options.utilities', + defaultMessage: 'Utilities', + description: 'The label for the Utilities work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.trade': { + id: 'account.settings.field.demographics.work_sector.options.trade', + defaultMessage: 'Wholesale Trade', + description: 'The label for the Wholesale Trade work sector option.', + }, + 'account.settings.field.demographics.work_sector.options.other': { + id: 'account.settings.field.demographics.work_sector.options.other', + defaultMessage: 'Other', + description: 'The label for the Other work sector option.', + }, + 'account.settings.field.demographics.options.declined': { + id: 'account.settings.field.demographics.options.declined', + defaultMessage: 'Prefer not to respond', + description: 'The label for the declined option.', + }, +}); + +export default messages; diff --git a/src/account-settings/demographics/data/service.js b/src/account-settings/demographics/data/service.js new file mode 100644 index 0000000..69ed3e0 --- /dev/null +++ b/src/account-settings/demographics/data/service.js @@ -0,0 +1,63 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; +import { convertData, TO, FROM } from './utils'; + +/** + * post all of the data related to demographics. + * @param {Number} userId users are identified in the api by LMS id + * @param {Object} commitValues { demographics } + */ +export async function postDemographics(userId) { + const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/`; + const commitValues = { user: userId }; + let data = {}; + + ({ data } = await getAuthenticatedHttpClient() + .post(requestUrl, commitValues) + .catch((error) => { + const apiError = Object.create(error); + throw apiError; + })); + + return convertData(data, FROM); +} + +/** + * get all data related to the demographics. + * @param {Number} userId users are identified in the api by LMS id + */ +export async function getDemographics(userId) { + const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/${userId}/`; + let data = {}; + + try { + ({ data } = await getAuthenticatedHttpClient() + .get(requestUrl)); + + data = convertData(data, FROM); + } catch (error) { + data = await postDemographics(userId); + } + + return data; +} + +/** + * patch all of the data related to demographics. + * @param {Number} userId users are identified in the api by LMS id + * @param {Object} commitValues { demographics } + */ +export async function patchDemographics(userId, commitValues) { + const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/${userId}/`; + const convertedCommitValues = convertData(commitValues, TO); + let data = {}; + + ({ data } = await getAuthenticatedHttpClient() + .patch(requestUrl, convertedCommitValues) + .catch((error) => { + const apiError = Object.create(error); + throw apiError; + })); + + return convertData(data, FROM); +} diff --git a/src/account-settings/demographics/data/utils.js b/src/account-settings/demographics/data/utils.js new file mode 100644 index 0000000..6208352 --- /dev/null +++ b/src/account-settings/demographics/data/utils.js @@ -0,0 +1,50 @@ +export const TO = 'to'; +export const FROM = 'from'; + +// Frontend wants (example): +// demographics_user_ethnicity: ["asian", "white", "other"] +// +// Demographics wants (example): +// user_ethnicity: [ +// { ethnicity: "asian" }, +// { ethnicity: "white" }, +// { ethnicity: "other" } +// ] +function convertEthnicity(ethnicityData, direction) { + if (direction === FROM) { + return ethnicityData.map(e => e.ethnicity); + } + + if (direction === TO) { + return ethnicityData.map(e => ({ ethnicity: e })); + } + + return ethnicityData; +} + +// Handles conversion of data to/from Demographics IDA to/from format needed for +// frontend +// * handles ethnicity field +// * adds/removes 'demographics' to/from key +// * replace `null` with empty string or empty string with null +export function convertData(dataObject, direction) { + const converted = {}; + + Object.entries(dataObject).forEach(([key, value]) => { + let newValue = value; + + if (key.includes('ethnicity')) { + newValue = convertEthnicity(value, direction); + } + + if (direction === TO) { + converted[key.replace('demographics_', '')] = newValue || null; + } + + if (direction === FROM) { + converted[`demographics_${key}`] = newValue || ''; + } + }); + + return converted; +} diff --git a/src/index.jsx b/src/index.jsx index 42dda62..3a5dde9 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -64,6 +64,8 @@ initialize({ mergeConfig({ SUPPORT_URL: process.env.SUPPORT_URL, COACHING_ENABLED: (process.env.COACHING_ENABLED || false), + ENABLE_DEMOGRAPHICS_COLLECTION: (process.env.ENABLE_DEMOGRAPHICS_COLLECTION || false), + DEMOGRAPHICS_BASE_URL: process.env.DEMOGRAPHICS_BASE_URL, }, 'App loadConfig override handler'); }, }, diff --git a/src/index.scss b/src/index.scss index 165d90a..1ec3387 100755 --- a/src/index.scss +++ b/src/index.scss @@ -51,3 +51,13 @@ $fa-font-path: "~font-awesome/fonts"; font-size: 0.75rem; } } + +.checkboxOption { + input:focus { + outline: -webkit-focus-ring-color auto 5px; + } + + label { + font-weight: normal; + } +}