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;
+}