Merge branch 'hack/log' of github.com:edx/frontend-app-account into hack/log
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-4">The email address you used to register with edX.</p>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
<label htmlFor="loginUsername" className="h6 mr-1">Password</label>
|
||||
<label htmlFor="loginPassword" className="h6 mr-1">Password</label>
|
||||
<Input
|
||||
name="Password"
|
||||
name="password"
|
||||
id="loginPassword"
|
||||
type="Password"
|
||||
type="password"
|
||||
value={this.state.password}
|
||||
onChange={this.handlePasswordChange}
|
||||
onChange={e => this.handlePasswordChange(e)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
24
src/registration/data/actions.js
Normal file
24
src/registration/data/actions.js
Normal file
@@ -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 },
|
||||
});
|
||||
33
src/registration/data/reducers.js
Normal file
33
src/registration/data/reducers.js
Normal file
@@ -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;
|
||||
118
src/registration/data/sagas.js
Normal file
118
src/registration/data/sagas.js
Normal file
@@ -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(),
|
||||
]);
|
||||
}
|
||||
161
src/registration/data/selectors.js
Normal file
161
src/registration/data/selectors.js
Normal file
@@ -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,
|
||||
}),
|
||||
);
|
||||
194
src/registration/data/service.js
Normal file
194
src/registration/data/service.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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?"`;
|
||||
38
src/registration/data/utils/dataUtils.js
Normal file
38
src/registration/data/utils/dataUtils.js
Normal file
@@ -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);
|
||||
}
|
||||
90
src/registration/data/utils/dataUtils.test.js
Normal file
90
src/registration/data/utils/dataUtils.test.js
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
12
src/registration/data/utils/index.js
Normal file
12
src/registration/data/utils/index.js
Normal file
@@ -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';
|
||||
62
src/registration/data/utils/reduxUtils.js
Normal file
62
src/registration/data/utils/reduxUtils.js
Normal file
@@ -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);
|
||||
}
|
||||
51
src/registration/data/utils/reduxUtils.test.js
Normal file
51
src/registration/data/utils/reduxUtils.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
16
src/registration/data/utils/sagaUtils.js
Normal file
16
src/registration/data/utils/sagaUtils.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
48
src/registration/data/utils/serviceUtils.js
Normal file
48
src/registration/data/utils/serviceUtils.js
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user