From 2e5e5a1d3d904a9c14e135993a8a466058b1ee92 Mon Sep 17 00:00:00 2001 From: Rick Reilly Date: Thu, 13 Feb 2020 11:45:31 -0500 Subject: [PATCH] wip --- src/registration/LoginPage.jsx | 10 +- src/registration/data/actions.js | 24 +++ src/registration/data/reducers.js | 33 +++ src/registration/data/sagas.js | 118 +++++++++++ src/registration/data/selectors.js | 161 +++++++++++++++ src/registration/data/service.js | 194 ++++++++++++++++++ .../__snapshots__/reduxUtils.test.js.snap | 3 + src/registration/data/utils/dataUtils.js | 38 ++++ src/registration/data/utils/dataUtils.test.js | 90 ++++++++ src/registration/data/utils/index.js | 12 ++ src/registration/data/utils/reduxUtils.js | 62 ++++++ .../data/utils/reduxUtils.test.js | 51 +++++ src/registration/data/utils/sagaUtils.js | 16 ++ src/registration/data/utils/serviceUtils.js | 48 +++++ 14 files changed, 855 insertions(+), 5 deletions(-) create mode 100644 src/registration/data/actions.js create mode 100644 src/registration/data/reducers.js create mode 100644 src/registration/data/sagas.js create mode 100644 src/registration/data/selectors.js create mode 100644 src/registration/data/service.js create mode 100644 src/registration/data/utils/__snapshots__/reduxUtils.test.js.snap create mode 100644 src/registration/data/utils/dataUtils.js create mode 100644 src/registration/data/utils/dataUtils.test.js create mode 100644 src/registration/data/utils/index.js create mode 100644 src/registration/data/utils/reduxUtils.js create mode 100644 src/registration/data/utils/reduxUtils.test.js create mode 100644 src/registration/data/utils/sagaUtils.js create mode 100644 src/registration/data/utils/serviceUtils.js diff --git a/src/registration/LoginPage.jsx b/src/registration/LoginPage.jsx index d5d925e..5594091 100644 --- a/src/registration/LoginPage.jsx +++ b/src/registration/LoginPage.jsx @@ -40,18 +40,18 @@ export default class LoginPage extends React.Component { type="email" placeholder="username@domain.com" value={this.state.username} - onChange={this.handleUsernameChange} + onChange={e => this.handleUsernameChange(e)} />

The email address you used to register with edX.

- + this.handlePasswordChange(e)} />
diff --git a/src/registration/data/actions.js b/src/registration/data/actions.js new file mode 100644 index 0000000..0ea436d --- /dev/null +++ b/src/registration/data/actions.js @@ -0,0 +1,24 @@ +import { AsyncActionType } from './utils'; + +export const REGISTER_NEW_USER = new AsyncActionType('REGISTRATION', 'REGISTER_NEW_USER'); + +// SAVE SETTINGS ACTIONS + +export const registerNewUser = (formId, commitValues) => ({ + type: REGISTER_NEW_USER.BASE, + payload: { formId, commitValues }, +}); + +export const registerNewUserBegin = () => ({ + type: REGISTER_NEW_USER.BEGIN, +}); + +export const registerNewUserSuccess = (values, confirmationValues) => ({ + type: REGISTER_NEW_USER.SUCCESS, + payload: { values, confirmationValues }, +}); + +export const registerNewUserFailure = ({ fieldErrors, message }) => ({ + type: REGISTER_NEW_USER.FAILURE, + payload: { errors: fieldErrors, message }, +}); diff --git a/src/registration/data/reducers.js b/src/registration/data/reducers.js new file mode 100644 index 0000000..80d9ed4 --- /dev/null +++ b/src/registration/data/reducers.js @@ -0,0 +1,33 @@ +import { + REGISTER_NEW_USER, +} from './actions'; + +import { reducer as deleteAccountReducer, DELETE_ACCOUNT } from '../delete-account'; +import { reducer as siteLanguageReducer, FETCH_SITE_LANGUAGES } from '../site-language'; +import { reducer as resetPasswordReducer, RESET_PASSWORD } from '../reset-password'; +import { reducer as thirdPartyAuthReducer, DISCONNECT_AUTH } from '../third-party-auth'; + +export const defaultState = { + registrationResult: {}, +}; + +const reducer = (state = defaultState, action) => { + switch (action.type) { + case REGISTER_NEW_USER.BEGIN: + return { + ...state, + }; + case REGISTER_NEW_USER.SUCCESS: + return { + ...state, + }; + case REGISTER_NEW_USER.FAILURE: + return { + ...state, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/registration/data/sagas.js b/src/registration/data/sagas.js new file mode 100644 index 0000000..fe9c8b6 --- /dev/null +++ b/src/registration/data/sagas.js @@ -0,0 +1,118 @@ +import { call, put, delay, takeEvery, all } from 'redux-saga/effects'; + +import { publish } from '@edx/frontend-platform'; +import { getLocale, handleRtl, LOCALE_CHANGED } from '@edx/frontend-platform/i18n'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; + +// Actions +import { + FETCH_SETTINGS, + fetchSettingsBegin, + fetchSettingsSuccess, + fetchSettingsFailure, + closeForm, + SAVE_SETTINGS, + saveSettingsBegin, + saveSettingsSuccess, + saveSettingsFailure, + savePreviousSiteLanguage, + FETCH_TIME_ZONES, + fetchTimeZones, + fetchTimeZonesSuccess, +} from './actions'; + +// Sub-modules +import { saga as deleteAccountSaga } from '../delete-account'; +import { saga as resetPasswordSaga } from '../reset-password'; +import { + saga as siteLanguageSaga, + patchPreferences, + postSetLang, +} from '../site-language'; +import { saga as thirdPartyAuthSaga } from '../third-party-auth'; + +// Services +import { getSettings, patchSettings, getTimeZones } from './service'; + +export function* handleFetchSettings() { + try { + yield put(fetchSettingsBegin()); + const { username, roles: userRoles } = getAuthenticatedUser(); + + const { + thirdPartyAuthProviders, profileDataManager, timeZones, ...values + } = yield call( + getSettings, + username, + userRoles, + ); + + if (values.country) yield put(fetchTimeZones(values.country)); + + yield put(fetchSettingsSuccess({ + values, + thirdPartyAuthProviders, + profileDataManager, + timeZones, + })); + } catch (e) { + yield put(fetchSettingsFailure(e.message)); + throw e; + } +} + +export function* handleSaveSettings(action) { + try { + yield put(saveSettingsBegin()); + + const { username } = getAuthenticatedUser(); + const { commitValues, formId } = action.payload; + const commitData = { [formId]: commitValues }; + let savedValues = null; + if (formId === 'siteLanguage') { + const previousSiteLanguage = getLocale(); + // The following two requests need to be done sequentially, with patching preferences before + // the post to setlang. They used to be done in parallel, but this might create ambiguous + // behavior. + yield call(patchPreferences, username, { prefLang: commitValues }); + yield call(postSetLang, commitValues); + + yield put(savePreviousSiteLanguage(previousSiteLanguage)); + + publish(LOCALE_CHANGED, getLocale()); + handleRtl(); + savedValues = commitData; + } else { + savedValues = yield call(patchSettings, username, commitData); + } + yield put(saveSettingsSuccess(savedValues, commitData)); + if (savedValues.country) yield put(fetchTimeZones(savedValues.country)); + yield delay(1000); + yield put(closeForm(action.payload.formId)); + } catch (e) { + if (e.fieldErrors) { + yield put(saveSettingsFailure({ fieldErrors: e.fieldErrors })); + } else { + yield put(saveSettingsFailure(e.message)); + throw e; + } + } +} + +export function* handleFetchTimeZones(action) { + const response = yield call(getTimeZones, action.payload.country); + yield put(fetchTimeZonesSuccess(response, action.payload.country)); +} + + +export default function* saga() { + yield takeEvery(FETCH_SETTINGS.BASE, handleFetchSettings); + yield takeEvery(SAVE_SETTINGS.BASE, handleSaveSettings); + yield takeEvery(FETCH_TIME_ZONES.BASE, handleFetchTimeZones); + yield all([ + deleteAccountSaga(), + siteLanguageSaga(), + resetPasswordSaga(), + thirdPartyAuthSaga(), + ]); +} diff --git a/src/registration/data/selectors.js b/src/registration/data/selectors.js new file mode 100644 index 0000000..6d58b75 --- /dev/null +++ b/src/registration/data/selectors.js @@ -0,0 +1,161 @@ +import { createSelector, createStructuredSelector } from 'reselect'; + +import { siteLanguageOptionsSelector, siteLanguageListSelector } from '../site-language'; + +export const storeName = 'accountSettings'; + +export const accountSettingsSelector = state => ({ ...state[storeName] }); + +const editableFieldNameSelector = (state, props) => props.name; + +const valuesSelector = createSelector( + accountSettingsSelector, + accountSettings => accountSettings.values, +); + +const draftsSelector = createSelector( + accountSettingsSelector, + accountSettings => accountSettings.drafts, +); + +const previousSiteLanguageSelector = createSelector( + accountSettingsSelector, + accountSettings => accountSettings.previousSiteLanguage, +); + +const editableFieldErrorSelector = createSelector( + editableFieldNameSelector, + accountSettingsSelector, + (name, accountSettings) => accountSettings.errors[name], +); + +const editableFieldConfirmationValuesSelector = createSelector( + editableFieldNameSelector, + accountSettingsSelector, + (name, accountSettings) => accountSettings.confirmationValues[name], +); + +const isEditingSelector = createSelector( + editableFieldNameSelector, + accountSettingsSelector, + (name, accountSettings) => accountSettings.openFormId === name, +); + +const saveStateSelector = createSelector( + accountSettingsSelector, + accountSettings => accountSettings.saveState, +); + +export const editableFieldSelector = createStructuredSelector({ + error: editableFieldErrorSelector, + confirmationValue: editableFieldConfirmationValuesSelector, + saveState: saveStateSelector, + isEditing: isEditingSelector, +}); + +export const profileDataManagerSelector = createSelector( + accountSettingsSelector, + accountSettings => accountSettings.profileDataManager, +); + +export const staticFieldsSelector = createSelector( + accountSettingsSelector, + accountSettings => (accountSettings.profileDataManager ? ['name', 'email', 'country'] : []), +); + +export const hiddenFieldsSelector = createSelector( + accountSettingsSelector, + accountSettings => (accountSettings.profileDataManager ? [] : ['secondary_email']), +); + +/** + * If there's no draft present at all (undefined), use the original committed value. + */ +function chooseFormValue(draft, committed) { + return draft !== undefined ? draft : committed; +} + +const formValuesSelector = createSelector( + valuesSelector, + draftsSelector, + (values, drafts) => { + const formValues = {}; + Object.entries(values).forEach(([name, value]) => { + formValues[name] = chooseFormValue(drafts[name], value) || ''; + }); + return formValues; + }, +); + +const transformTimeZonesToOptions = timeZoneArr => timeZoneArr + .map(({ time_zone, description }) => ({ // eslint-disable-line camelcase + value: time_zone, label: description, + })); + +const timeZonesSelector = createSelector( + accountSettingsSelector, + accountSettings => transformTimeZonesToOptions(accountSettings.timeZones), +); + +const countryTimeZonesSelector = createSelector( + accountSettingsSelector, + accountSettings => transformTimeZonesToOptions(accountSettings.countryTimeZones), +); + +const activeAccountSelector = createSelector( + accountSettingsSelector, + accountSettings => accountSettings.values.is_active, +); + +export const siteLanguageSelector = createSelector( + previousSiteLanguageSelector, + draftsSelector, + (previousValue, drafts) => ({ + previousValue, + draft: drafts.siteLanguage, + }), +); + +export const betaLanguageBannerSelector = createStructuredSelector({ + siteLanguageList: siteLanguageListSelector, + siteLanguage: siteLanguageSelector, +}); + +export const accountSettingsPageSelector = createSelector( + accountSettingsSelector, + siteLanguageOptionsSelector, + siteLanguageSelector, + formValuesSelector, + profileDataManagerSelector, + staticFieldsSelector, + hiddenFieldsSelector, + timeZonesSelector, + countryTimeZonesSelector, + activeAccountSelector, + ( + accountSettings, + siteLanguageOptions, + siteLanguage, + formValues, + profileDataManager, + staticFields, + hiddenFields, + timeZoneOptions, + countryTimeZoneOptions, + activeAccount, + ) => ({ + siteLanguageOptions, + siteLanguage, + loading: accountSettings.loading, + loaded: accountSettings.loaded, + loadingError: accountSettings.loadingError, + timeZoneOptions, + countryTimeZoneOptions, + isActive: activeAccount, + formValues, + profileDataManager, + staticFields, + hiddenFields, + tpaProviders: accountSettings.thirdPartyAuth.providers, + }), +); diff --git a/src/registration/data/service.js b/src/registration/data/service.js new file mode 100644 index 0000000..c709c81 --- /dev/null +++ b/src/registration/data/service.js @@ -0,0 +1,194 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import pick from 'lodash.pick'; +import omit from 'lodash.omit'; +import isEmpty from 'lodash.isempty'; + +import { handleRequestError, unpackFieldErrors } from './utils'; +import { getThirdPartyAuthProviders } from '../third-party-auth'; + + +function unpackAccountResponseData(data) { + const unpackedData = data; + + // This is handled by preferences + delete unpackedData.time_zone; + + SOCIAL_PLATFORMS.forEach(({ id, key }) => { + const platformData = data.social_links.find(({ platform }) => platform === id); + unpackedData[key] = typeof platformData === 'object' ? platformData.social_link : ''; + }); + + if (Array.isArray(data.language_proficiencies)) { + if (data.language_proficiencies.length) { + unpackedData.language_proficiencies = data.language_proficiencies[0].code; + } else { + unpackedData.language_proficiencies = ''; + } + } + + return unpackedData; +} + +function packAccountCommitData(commitData) { + const packedData = commitData; + + SOCIAL_PLATFORMS.forEach(({ id, key }) => { + // Skip missing values. Empty strings are valid values and should be preserved. + if (commitData[key] === undefined) return; + + packedData.social_links = [{ platform: id, social_link: commitData[key] }]; + delete packedData[key]; + }); + + if (commitData.language_proficiencies !== undefined) { + if (commitData.language_proficiencies) { + packedData.language_proficiencies = [{ code: commitData.language_proficiencies }]; + } else { + // An empty string should be sent as an array. + packedData.language_proficiencies = []; + } + } + + if (commitData.year_of_birth !== undefined) { + if (commitData.year_of_birth) { + packedData.year_of_birth = commitData.year_of_birth; + } else { + // An empty string should be sent as null. + packedData.year_of_birth = null; + } + } + return packedData; +} + +export async function getAccount(username) { + const { data } = await getAuthenticatedHttpClient() + .get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`); + return unpackAccountResponseData(data); +} + +export async function patchAccount(username, commitValues) { + const requestConfig = { + headers: { 'Content-Type': 'application/merge-patch+json' }, + }; + + const { data } = await getAuthenticatedHttpClient() + .patch( + `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`, + packAccountCommitData(commitValues), + requestConfig, + ) + .catch((error) => { + const unpackFunction = (fieldErrors) => { + const unpackedFieldErrors = fieldErrors; + if (fieldErrors.social_links) { + SOCIAL_PLATFORMS.forEach(({ key }) => { + unpackedFieldErrors[key] = fieldErrors.social_links; + }); + } + return unpackFieldErrors(unpackedFieldErrors); + }; + handleRequestError(error, unpackFunction); + }); + + return unpackAccountResponseData(data); +} + +export async function getPreferences(username) { + const { data } = await getAuthenticatedHttpClient() + .get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`); + return data; +} + +export async function patchPreferences(username, commitValues) { + const requestConfig = { headers: { 'Content-Type': 'application/merge-patch+json' } }; + const requestUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`; + + // Ignore the success response, the API does not currently return any data. + await getAuthenticatedHttpClient() + .patch(requestUrl, commitValues, requestConfig).catch(handleRequestError); + + return commitValues; +} + +export async function getTimeZones(forCountry) { + const { data } = await getAuthenticatedHttpClient() + .get(`${getConfig().LMS_BASE_URL}/user_api/v1/preferences/time_zones/`, { + params: { country_code: forCountry }, + }) + .catch(handleRequestError); + + return data; +} + +/** + * Determine if the user's profile data is managed by a third-party identity provider. + */ +export async function getProfileDataManager(username, userRoles) { + const userRoleNames = userRoles.map(role => role.split(':')[0]); + + if (userRoleNames.includes('enterprise_learner')) { + const url = `${getConfig().LMS_BASE_URL}/enterprise/api/v1/enterprise-learner/?username=${username}`; + const { data } = await getAuthenticatedHttpClient().get(url).catch(handleRequestError); + + if ('results' in data) { + for (let i = 0; i < data.results.length; i += 1) { + const enterprise = data.results[i].enterprise_customer; + if (enterprise.sync_learner_profile_data) { + return enterprise.name; + } + } + } + } + + return null; +} + +/** + * A single function to GET everything considered a setting. + * Currently encapsulates Account, Preferences, and ThirdPartyAuth + */ +export async function getSettings(username, userRoles) { + const results = await Promise.all([ + getAccount(username), + getPreferences(username), + getThirdPartyAuthProviders(), + getProfileDataManager(username, userRoles), + getTimeZones(), + ]); + + return { + ...results[0], + ...results[1], + thirdPartyAuthProviders: results[2], + profileDataManager: results[3], + timeZones: results[4], + }; +} + +/** + * A single function to PATCH everything considered a setting. + * Currently encapsulates Account, Preferences, and ThirdPartyAuth + */ +export async function patchSettings(username, commitValues) { + // Note: time_zone exists in the return value from user/v1/accounts + // but it is always null and won't update. It also exists in + // user/v1/preferences where it does update. This is the one we use. + const preferenceKeys = ['time_zone']; + const accountCommitValues = omit(commitValues, preferenceKeys); + const preferenceCommitValues = pick(commitValues, preferenceKeys); + const patchRequests = []; + + if (!isEmpty(accountCommitValues)) { + patchRequests.push(patchAccount(username, accountCommitValues)); + } + if (!isEmpty(preferenceCommitValues)) { + patchRequests.push(patchPreferences(username, preferenceCommitValues)); + } + + const results = await Promise.all(patchRequests); + // Assigns in order of requests. Preference keys + // will override account keys. Notably time_zone. + const combinedResults = Object.assign({}, ...results); + return combinedResults; +} diff --git a/src/registration/data/utils/__snapshots__/reduxUtils.test.js.snap b/src/registration/data/utils/__snapshots__/reduxUtils.test.js.snap new file mode 100644 index 0000000..5571ec8 --- /dev/null +++ b/src/registration/data/utils/__snapshots__/reduxUtils.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getModuleState should throw an exception on a bad path 1`] = `"Unexpected state key uhoh given to getModuleState. Is your state path set up correctly?"`; diff --git a/src/registration/data/utils/dataUtils.js b/src/registration/data/utils/dataUtils.js new file mode 100644 index 0000000..cb1255e --- /dev/null +++ b/src/registration/data/utils/dataUtils.js @@ -0,0 +1,38 @@ +import camelCase from 'lodash.camelcase'; +import snakeCase from 'lodash.snakecase'; + +export function modifyObjectKeys(object, modify) { + // If the passed in object is not an object, return it. + if ( + object === undefined || + object === null || + (typeof object !== 'object' && !Array.isArray(object)) + ) { + return object; + } + + if (Array.isArray(object)) { + return object.map(value => modifyObjectKeys(value, modify)); + } + + // Otherwise, process all its keys. + const result = {}; + Object.entries(object).forEach(([key, value]) => { + result[modify(key)] = modifyObjectKeys(value, modify); + }); + return result; +} + +export function camelCaseObject(object) { + return modifyObjectKeys(object, camelCase); +} + +export function snakeCaseObject(object) { + return modifyObjectKeys(object, snakeCase); +} + +export function convertKeyNames(object, nameMap) { + const transformer = key => (nameMap[key] === undefined ? key : nameMap[key]); + + return modifyObjectKeys(object, transformer); +} diff --git a/src/registration/data/utils/dataUtils.test.js b/src/registration/data/utils/dataUtils.test.js new file mode 100644 index 0000000..dee7558 --- /dev/null +++ b/src/registration/data/utils/dataUtils.test.js @@ -0,0 +1,90 @@ +import { + modifyObjectKeys, + camelCaseObject, + snakeCaseObject, + convertKeyNames, +} from './dataUtils'; + +describe('modifyObjectKeys', () => { + it('should use the provided modify function to change all keys in and object and its children', () => { + function meowKeys(key) { + return `${key}Meow`; + } + + const result = modifyObjectKeys( + { + one: undefined, + two: null, + three: '', + four: 0, + five: NaN, + six: [1, 2, { seven: 'woof' }], + eight: { nine: { ten: 'bark' }, eleven: true }, + }, + meowKeys, + ); + + expect(result).toEqual({ + oneMeow: undefined, + twoMeow: null, + threeMeow: '', + fourMeow: 0, + fiveMeow: NaN, + sixMeow: [1, 2, { sevenMeow: 'woof' }], + eightMeow: { nineMeow: { tenMeow: 'bark' }, elevenMeow: true }, + }); + }); +}); + +describe('camelCaseObject', () => { + it('should make everything camelCase', () => { + const result = camelCaseObject({ + what_now: 'brown cow', + but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } }, + 'dot.dot.dot': 123, + }); + + expect(result).toEqual({ + whatNow: 'brown cow', + butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } }, + dotDotDot: 123, + }); + }); +}); + +describe('snakeCaseObject', () => { + it('should make everything snake_case', () => { + const result = snakeCaseObject({ + whatNow: 'brown cow', + butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } }, + 'dot.dot.dot': 123, + }); + + expect(result).toEqual({ + what_now: 'brown cow', + but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } }, + dot_dot_dot: 123, + }); + }); +}); + +describe('convertKeyNames', () => { + it('should replace the specified keynames', () => { + const result = convertKeyNames( + { + one: { two: { three: 'four' } }, + five: 'six', + }, + { + two: 'blue', + five: 'alive', + seven: 'heaven', + }, + ); + + expect(result).toEqual({ + one: { blue: { three: 'four' } }, + alive: 'six', + }); + }); +}); diff --git a/src/registration/data/utils/index.js b/src/registration/data/utils/index.js new file mode 100644 index 0000000..e8c75a2 --- /dev/null +++ b/src/registration/data/utils/index.js @@ -0,0 +1,12 @@ +export { + camelCaseObject, + convertKeyNames, + modifyObjectKeys, + snakeCaseObject, +} from './dataUtils'; +export { + AsyncActionType, + getModuleState, +} from './reduxUtils'; +export { default as handleFailure } from './sagaUtils'; +export { unpackFieldErrors, handleRequestError } from './serviceUtils'; diff --git a/src/registration/data/utils/reduxUtils.js b/src/registration/data/utils/reduxUtils.js new file mode 100644 index 0000000..0a75d19 --- /dev/null +++ b/src/registration/data/utils/reduxUtils.js @@ -0,0 +1,62 @@ +/** + * Helper class to save time when writing out action types for asynchronous methods. Also helps + * ensure that actions are namespaced. + */ +export class AsyncActionType { + constructor(topic, name) { + this.topic = topic; + this.name = name; + } + + get BASE() { + return `${this.topic}__${this.name}`; + } + + get BEGIN() { + return `${this.topic}__${this.name}__BEGIN`; + } + + get SUCCESS() { + return `${this.topic}__${this.name}__SUCCESS`; + } + + get FAILURE() { + return `${this.topic}__${this.name}__FAILURE`; + } + + get RESET() { + return `${this.topic}__${this.name}__RESET`; + } +} + +/** + * Given a state tree and an array representing a set of keys to traverse in that tree, returns + * the portion of the tree at that key path. + * + * Example: + * + * const result = getModuleState( + * { + * first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } }, + * second: { other: 'data', } + * }, + * ['first', 'red'] + * ); + * + * result will be: + * + * { + * awesome: 'sauce' + * } + */ +export function getModuleState(state, originalPath) { + const path = [...originalPath]; // don't modify your argument + if (path.length < 1) { + return state; + } + const key = path.shift(); + if (state[key] === undefined) { + throw new Error(`Unexpected state key ${key} given to getModuleState. Is your state path set up correctly?`); + } + return getModuleState(state[key], path); +} diff --git a/src/registration/data/utils/reduxUtils.test.js b/src/registration/data/utils/reduxUtils.test.js new file mode 100644 index 0000000..586a8ba --- /dev/null +++ b/src/registration/data/utils/reduxUtils.test.js @@ -0,0 +1,51 @@ +import { + AsyncActionType, + getModuleState, +} from './reduxUtils'; + +describe('AsyncActionType', () => { + it('should return well formatted action strings', () => { + const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE'); + + expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE'); + expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN'); + expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS'); + expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE'); + expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET'); + }); +}); + +describe('getModuleState', () => { + const state = { + first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } }, + second: { other: 'data' }, + }; + + it('should return everything if given an empty path', () => { + expect(getModuleState(state, [])).toEqual(state); + }); + + it('should resolve paths correctly', () => { + expect(getModuleState( + state, + ['first'], + )).toEqual({ red: { awesome: 'sauce' }, blue: { weak: 'sauce' } }); + + expect(getModuleState( + state, + ['first', 'red'], + )).toEqual({ awesome: 'sauce' }); + + expect(getModuleState(state, ['second'])).toEqual({ other: 'data' }); + }); + + it('should throw an exception on a bad path', () => { + expect(() => { + getModuleState(state, ['uhoh']); + }).toThrowErrorMatchingSnapshot(); + }); + + it('should return non-objects correctly', () => { + expect(getModuleState(state, ['first', 'red', 'awesome'])).toEqual('sauce'); + }); +}); diff --git a/src/registration/data/utils/sagaUtils.js b/src/registration/data/utils/sagaUtils.js new file mode 100644 index 0000000..0ce3ecd --- /dev/null +++ b/src/registration/data/utils/sagaUtils.js @@ -0,0 +1,16 @@ +import { put } from 'redux-saga/effects'; +import { logError } from '@edx/frontend-platform/logging'; +import { history } from '@edx/frontend-platform'; + +export default function* handleFailure(error, failureAction = null, failureRedirectPath = null) { + if (error.fieldErrors && failureAction !== null) { + yield put(failureAction({ fieldErrors: error.fieldErrors })); + } + logError(error); + if (failureAction !== null) { + yield put(failureAction(error.message)); + } + if (failureRedirectPath !== null) { + history.push(failureRedirectPath); + } +} diff --git a/src/registration/data/utils/serviceUtils.js b/src/registration/data/utils/serviceUtils.js new file mode 100644 index 0000000..ea22c04 --- /dev/null +++ b/src/registration/data/utils/serviceUtils.js @@ -0,0 +1,48 @@ +/** + * Turns field errors of the form: + * + * { + * "name":{ + * "developer_message": "Nerdy message here", + * "user_message": "This value is invalid." + * }, + * "other_field": { + * "developer_message": "Other Nerdy message here", + * "user_message": "This other value is invalid." + * } + * } + * + * Into: + * + * { + * "name": "This value is invalid.", + * "other_field": "This other value is invalid" + * } + */ +export function unpackFieldErrors(fieldErrors) { + return Object.entries(fieldErrors).reduce((acc, [k, v]) => { + acc[k] = v.user_message; + return acc; + }, {}); +} + +/** + * Processes and re-throws request errors. If the response contains a field_errors field, will + * massage the data into a form expected by the client. + * + * Field errors will be packaged as an api error with a fieldErrors field usable by the client. + * Takes an optional unpack function which is used to process the field errors, + * otherwise uses the default unpackFieldErrors function. + * + * @param error The original error object. + * @param unpackFunction (Optional) A function to use to unpack the field errors as a replacement + * for the default. + */ +export function handleRequestError(error, unpackFunction = unpackFieldErrors) { + if (error.response && error.response.data.field_errors) { + const apiError = Object.create(error); + apiError.fieldErrors = unpackFunction(error.response.data.field_errors); + throw apiError; + } + throw error; +}