First pass - realigning/simplifying/refactoring (#33)

Realigning and simplifying directories and naming.

- Combining “containers” into “components”.
- Flattening out “data” into “reducers” and “config” to consolidate configuration-like files in one place and to make reducers a peer of its teammates (components, actions, sagas, and services).
- Creating dev/prod-specific redux configurations.
- Converting “index.jsx” files into files named for their contents.
- Splitting up the top-level “index.jsx” file into an entry point and an “App” component.
- Renaming SCSS file to “index.scss” to keep it consistent with where it’s imported.
- Renaming/simplifying some variables.
This commit is contained in:
David Joy
2019-02-25 15:36:24 -05:00
committed by GitHub
parent 6e41a0a928
commit 0e1a3356aa
38 changed files with 346 additions and 421 deletions

View File

@@ -1,29 +1,42 @@
import AsyncActionType from './AsyncActionType';
export const EDITABLE_FIELD_OPEN = 'EDITABLE_FIELD_OPEN';
export const EDITABLE_FIELD_CLOSE = 'EDITABLE_FIELD_CLOSE';
export const FIELD_OPEN = 'FIELD_OPEN';
export const FIELD_CLOSE = 'FIELD_CLOSE';
export const FETCH_PROFILE = new AsyncActionType('PROFILE', 'FETCH_PROFILE');
export const SAVE_PROFILE = new AsyncActionType('PROFILE', 'SAVE_PROFILE');
export const SAVE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'SAVE_PROFILE_PHOTO');
export const DELETE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'DELETE_PROFILE_PHOTO');
export const UPDATE_DRAFTS = 'UPDATE_DRAFTS';
export const RECEIVE_PREFERENCES = 'RECEIVE_PREFERENCES';
export const openEditableField = fieldName => ({
type: EDITABLE_FIELD_OPEN,
export const openField = fieldName => ({
type: FIELD_OPEN,
fieldName,
});
export const closeEditableField = fieldName => ({
type: EDITABLE_FIELD_CLOSE,
export const closeField = fieldName => ({
type: FIELD_CLOSE,
fieldName,
});
export const updateDrafts = drafts => ({
type: UPDATE_DRAFTS,
drafts,
});
export const fetchProfileBegin = () => ({
type: FETCH_PROFILE.BEGIN,
});
export const fetchProfileSuccess = profile => ({
type: FETCH_PROFILE.SUCCESS,
payload: { profile },
profile,
});
export const receivePreferences = preferences => ({
type: RECEIVE_PREFERENCES,
preferences,
});
export const fetchProfileFailure = error => ({
@@ -57,12 +70,13 @@ export const saveProfileFailure = error => ({
payload: { error },
});
export const saveProfile = (username, userAccountState, fieldName) => ({
export const saveProfile = (username, { profileData, preferencesData }, fieldName) => ({
type: SAVE_PROFILE.BASE,
payload: {
fieldName,
username,
userAccountState,
profileData,
preferencesData,
},
});

View File

@@ -1,8 +1,8 @@
import {
openEditableField,
closeEditableField,
EDITABLE_FIELD_OPEN,
EDITABLE_FIELD_CLOSE,
openField,
closeField,
FIELD_OPEN,
FIELD_CLOSE,
SAVE_PROFILE,
saveProfileBegin,
saveProfileSuccess,
@@ -21,28 +21,28 @@ import {
deleteProfilePhotoFailure,
deleteProfilePhotoReset,
deleteProfilePhoto,
} from './profile';
} from './ProfileActions';
describe('editable field actions', () => {
it('should create an open action', () => {
const expectedAction = {
type: EDITABLE_FIELD_OPEN,
type: FIELD_OPEN,
fieldName: 'name',
};
expect(openEditableField('name')).toEqual(expectedAction);
expect(openField('name')).toEqual(expectedAction);
});
it('should create a closed action', () => {
const expectedAction = {
type: EDITABLE_FIELD_CLOSE,
type: FIELD_CLOSE,
fieldName: 'name',
};
expect(closeEditableField('name')).toEqual(expectedAction);
expect(closeField('name')).toEqual(expectedAction);
});
});
describe('SAVE profile actions', () => {
const userAccountState = {
const profileData = {
username: 'verified',
email: 'verified@example.com',
bio: 'A great bio.',
@@ -51,16 +51,19 @@ describe('SAVE profile actions', () => {
// Good enough for testing / and since we have no factories
};
const preferencesData = {};
it('should create an action to signal the start of a profile save', () => {
const expectedAction = {
type: SAVE_PROFILE.BASE,
payload: {
username: 'user person',
userAccountState,
fieldName: 'fullName',
profileData,
preferencesData,
},
};
expect(saveProfile('user person', userAccountState, 'fullName')).toEqual(expectedAction);
expect(saveProfile('user person', { profileData, preferencesData }, 'fullName')).toEqual(expectedAction);
});
it('should create an action to signal user profile save success', () => {
@@ -188,17 +191,17 @@ describe('Editable field opening and closing actions', () => {
it('should create an action to signal the opening a field', () => {
const expectedAction = {
type: EDITABLE_FIELD_OPEN,
type: FIELD_OPEN,
fieldName,
};
expect(openEditableField(fieldName)).toEqual(expectedAction);
expect(openField(fieldName)).toEqual(expectedAction);
});
it('should create an action to signal the closing a field', () => {
const expectedAction = {
type: EDITABLE_FIELD_CLOSE,
type: FIELD_CLOSE,
fieldName,
};
expect(closeEditableField(fieldName)).toEqual(expectedAction);
expect(closeField(fieldName)).toEqual(expectedAction);
});
});

View File

@@ -1,51 +0,0 @@
import AsyncActionType from './AsyncActionType';
export const FETCH_PREFERENCES = new AsyncActionType('PROFILE', 'FETCH_PREFERENCES');
export const SAVE_PREFERENCES = new AsyncActionType('PROFILE', 'SAVE_PREFERENCES');
export const fetchPreferencesBegin = () => ({
type: FETCH_PREFERENCES.BEGIN,
});
export const fetchPreferencesSuccess = preferences => ({
type: FETCH_PREFERENCES.SUCCESS,
preferences,
});
export const fetchPreferencesFailure = error => ({
type: FETCH_PREFERENCES.FAILURE,
payload: { error },
});
export const fetchPreferencesReset = () => ({
type: FETCH_PREFERENCES.RESET,
});
export const fetchPreferences = username => ({
type: FETCH_PREFERENCES.BASE,
payload: { username },
});
export const savePreferencesBegin = () => ({
type: SAVE_PREFERENCES.BEGIN,
});
export const savePreferencesSuccess = () => ({
type: SAVE_PREFERENCES.SUCCESS,
});
export const savePreferencesFailure = error => ({
type: SAVE_PREFERENCES.FAILURE,
payload: { error },
});
export const savePreferencesReset = () => ({
type: SAVE_PREFERENCES.RESET,
});
export const savePreferences = (username, preferences) => ({
type: SAVE_PREFERENCES.BASE,
payload: { username, preferences },
});

View File

@@ -1,4 +1,4 @@
import apiClient from './data/apiClient';
import apiClient from './config/apiClient';
const handleTrackEvents = (eventName, properties) => {
// Simply forward track events to Segment

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

85
src/components/App.jsx Normal file
View File

@@ -0,0 +1,85 @@
import React, { Component } from 'react';
import { connect, Provider } from 'react-redux';
import PropTypes from 'prop-types';
import { IntlProvider } from 'react-intl';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import SiteFooter from '@edx/frontend-component-footer';
import { fetchUserAccount, UserAccountApiService } from '@edx/frontend-auth';
import apiClient from '../config/apiClient';
import { handleTrackEvents } from '../analytics';
import { getLocale, getMessages } from '../i18n/i18n-loader';
import SiteHeader from './/SiteHeader';
import ConnectedProfilePage from './ProfilePage';
import HeaderLogo from '../../assets/edx-sm.png';
import FooterLogo from '../../assets/edx-footer.png';
import NotFoundPage from './NotFoundPage';
class App extends Component {
componentDidMount() {
const { username } = this.props;
const userAccountApiService = new UserAccountApiService(apiClient, process.env.LMS_BASE_URL);
this.props.fetchUserAccount(userAccountApiService, username);
}
render() {
return (
<IntlProvider locale={getLocale()} messages={getMessages()}>
<Provider store={this.props.store}>
<Router>
<div>
<SiteHeader
logo={HeaderLogo}
logoDestination={process.env.MARKETING_SITE_BASE_URL}
logoAltText={process.env.SITE_NAME}
/>
<main>
<Switch>
<Route path="/u/:username" component={ConnectedProfilePage} />
<Route path="*" component={NotFoundPage} />
</Switch>
</main>
<SiteFooter
siteName={process.env.SITE_NAME}
siteLogo={FooterLogo}
marketingSiteBaseUrl={process.env.MARKETING_SITE_BASE_URL}
supportUrl={process.env.SUPPORT_URL}
contactUrl={process.env.CONTACT_URL}
openSourceUrl={process.env.OPEN_SOURCE_URL}
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
facebookUrl={process.env.FACEBOOK_URL}
twitterUrl={process.env.TWITTER_URL}
youTubeUrl={process.env.YOU_TUBE_URL}
linkedInUrl={process.env.LINKED_IN_URL}
googlePlusUrl={process.env.GOOGLE_PLUS_URL}
redditUrl={process.env.REDDIT_URL}
appleAppStoreUrl={process.env.APPLE_APP_STORE_URL}
googlePlayUrl={process.env.GOOGLE_PLAY_URL}
handleAllTrackEvents={handleTrackEvents}
/>
</div>
</Router>
</Provider>
</IntlProvider>
);
}
}
App.propTypes = {
fetchUserAccount: PropTypes.func.isRequired,
username: PropTypes.string.isRequired,
store: PropTypes.object.isRequired, // eslint-disable-line
};
const mapStateToProps = state => ({
username: state.authentication.username,
});
export default connect(
mapStateToProps,
{
fetchUserAccount,
},
)(App);

View File

@@ -1,16 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Container, Row, Col } from 'reactstrap';
import { connect } from 'react-redux';
import ProfileAvatar from './ProfileAvatar';
import FullName from './FullName';
import UserLocation from './UserLocation';
import Education from './Education';
import SocialLinks from './SocialLinks';
import Bio from './Bio';
import MyCertificates from './MyCertificates';
// Actions
import {
fetchProfile,
saveProfile,
saveProfilePhoto,
deleteProfilePhoto,
openField,
closeField,
} from '../actions/ProfileActions';
class UserProfile extends React.Component {
// Components
import ProfileAvatar from './ProfilePage/ProfileAvatar';
import FullName from './ProfilePage/FullName';
import UserLocation from './ProfilePage/UserLocation';
import Education from './ProfilePage/Education';
import SocialLinks from './ProfilePage/SocialLinks';
import Bio from './ProfilePage/Bio';
import MyCertificates from './ProfilePage/MyCertificates';
export class ProfilePage extends React.Component {
constructor(props) {
super(props);
@@ -23,7 +35,6 @@ class UserProfile extends React.Component {
certificates: { value: null, visibility: null },
};
this.onCancel = this.onCancel.bind(this);
this.onEdit = this.onEdit.bind(this);
this.onSave = this.onSave.bind(this);
@@ -38,45 +49,32 @@ class UserProfile extends React.Component {
}
onCancel() {
this.props.closeEditableField(this.props.currentlyEditingField);
this.props.closeField(this.props.currentlyEditingField);
}
onEdit(fieldName) {
this.props.openEditableField(fieldName);
this.props.openField(fieldName);
}
onSave(fieldName, value, visibility) {
onSave(fieldName) {
const {
value: fieldValue,
visibility: fieldVisibility,
value,
visibility,
} = this.state[fieldName];
const valueToSave = value != null ? value : fieldValue;
const visibilityToSave = visibility != null ? visibility : fieldVisibility;
const data = {};
if (valueToSave != null) {
this.props.saveProfile(
this.props.username,
{
[fieldName]: valueToSave,
},
fieldName,
);
if (value != null) {
data.profileData = { [fieldName]: value };
}
if (visibility != null) {
data.preferencesData = { visibility: { [fieldName]: visibility } };
}
if (visibilityToSave != null) {
this.props.savePreferences(
this.props.username,
{
visibility: {
[fieldName]: visibilityToSave,
},
},
);
}
if (valueToSave == null && visibilityToSave == null) {
if (value == null && visibility == null) {
this.onCancel();
} else {
this.props.saveProfile(this.props.username, data, fieldName);
}
}
@@ -96,6 +94,7 @@ class UserProfile extends React.Component {
},
});
}
onVisibilityChange(fieldName, visibility) {
this.setState({
[fieldName]: {
@@ -233,9 +232,7 @@ class UserProfile extends React.Component {
}
}
export default UserProfile;
UserProfile.propTypes = {
ProfilePage.propTypes = {
currentlyEditingField: PropTypes.string,
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
@@ -255,13 +252,13 @@ UserProfile.propTypes = {
certificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
})),
fetchProfile: PropTypes.func.isRequired,
saveProfile: PropTypes.func.isRequired,
saveProfilePhoto: PropTypes.func.isRequired,
deleteProfilePhoto: PropTypes.func.isRequired,
openEditableField: PropTypes.func.isRequired,
closeEditableField: PropTypes.func.isRequired,
savePreferences: PropTypes.func.isRequired,
openField: PropTypes.func.isRequired,
closeField: PropTypes.func.isRequired,
match: PropTypes.shape({
params: PropTypes.shape({
username: PropTypes.string.isRequired,
@@ -271,7 +268,7 @@ UserProfile.propTypes = {
visibility: PropTypes.object, // eslint-disable-line
};
UserProfile.defaultProps = {
ProfilePage.defaultProps = {
currentlyEditingField: null,
saveState: null,
savePhotoState: null,
@@ -288,3 +285,39 @@ UserProfile.defaultProps = {
accountPrivacy: null,
visibility: {}, // eslint-disable-line
};
const mapStateToProps = (state) => {
const profileImage =
state.profilePage.profile.profileImage != null
? state.profilePage.profile.profileImage.imageUrlLarge
: null;
return {
isCurrentUserProfile: state.userAccount.username === state.profilePage.profile.username,
currentlyEditingField: state.profilePage.currentlyEditingField,
saveState: state.profilePage.saveState,
savePhotoState: state.profilePage.savePhotoState,
error: state.profilePage.error,
profileImage,
fullName: state.profilePage.profile.name,
username: state.profilePage.profile.username,
userLocation: state.profilePage.profile.country,
education: state.profilePage.profile.levelOfEducation,
socialLinks: state.profilePage.profile.socialLinks,
bio: state.profilePage.profile.bio,
certificates: state.profilePage.profile.certificates,
accountPrivacy: state.profilePage.preferences.accountPrivacy,
visibility: state.profilePage.preferences.visibility || {},
};
};
export default connect(
mapStateToProps,
{
fetchProfile,
saveProfile,
saveProfilePhoto,
deleteProfilePhoto,
openField,
closeField,
},
)(ProfilePage);

View File

@@ -1,7 +1,6 @@
import { getAuthenticatedAPIClient } from '@edx/frontend-auth';
import { configuration } from '../config';
import { configuration } from './environment';
const apiClient = getAuthenticatedAPIClient({
appBaseUrl: configuration.BASE_URL,
@@ -14,5 +13,4 @@ const apiClient = getAuthenticatedAPIClient({
csrfCookieName: configuration.CSRF_COOKIE_NAME,
});
export default apiClient;

View File

@@ -0,0 +1,25 @@
import { applyMiddleware, createStore } from 'redux';
import createSagaMiddleware from 'redux-saga';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import apiClient from './apiClient';
import reducers from '../reducers/RootReducer';
import rootSaga from '../sagas/RootSaga';
export default function configureStore() {
const loggerMiddleware = createLogger();
const sagaMiddleware = createSagaMiddleware();
const initialState = apiClient.getAuthenticationState();
const store = createStore(
reducers,
initialState,
composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware)),
);
sagaMiddleware.run(rootSaga);
return store;
}

View File

@@ -0,0 +1,9 @@
import { configuration } from './environment';
import configureStoreProd from './configureStore.prod';
import configureStoreDev from './configureStore.dev';
if (configuration.ENVIRONMENT === 'production') {
module.exports = configureStoreProd;
} else {
module.exports = configureStoreDev;
}

View File

@@ -0,0 +1,22 @@
import { applyMiddleware, createStore } from 'redux';
import createSagaMiddleware from 'redux-saga';
import thunkMiddleware from 'redux-thunk';
import apiClient from './apiClient';
import reducers from '../reducers/RootReducer';
import rootSaga from '../sagas/RootSaga';
export default function configureStore() {
const sagaMiddleware = createSagaMiddleware();
const initialState = apiClient.getAuthenticationState();
const store = createStore(
reducers,
initialState,
applyMiddleware(thunkMiddleware, sagaMiddleware),
);
sagaMiddleware.run(rootSaga);
return store;
}

View File

@@ -1,4 +1,4 @@
const configuration = {
export const configuration = {
BASE_URL: process.env.BASE_URL,
LMS_BASE_URL: process.env.LMS_BASE_URL,
LOGIN_URL: process.env.LOGIN_URL,
@@ -10,8 +10,7 @@ const configuration = {
SEGMENT_KEY: process.env.SEGMENT_KEY,
ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
CSRF_COOKIE_NAME: process.env.CSRF_COOKIE_NAME,
ENVIRONMENT: process.env.NODE_ENV,
};
const features = {};
export { configuration, features };
export const features = {};

View File

@@ -1,49 +0,0 @@
import { connect } from 'react-redux';
import UserProfile from '../../components/UserProfile';
import {
fetchProfile,
saveProfile,
saveProfilePhoto,
deleteProfilePhoto,
openEditableField,
closeEditableField,
} from '../../actions/profile';
import { savePreferences } from '../../actions/preferences';
const mapStateToProps = (state) => {
const profileImage =
state.profilePage.profile.profileImage != null
? state.profilePage.profile.profileImage.imageUrlLarge
: null;
return {
isCurrentUserProfile: state.userAccount.username === state.profilePage.profile.username,
currentlyEditingField: state.profilePage.currentlyEditingField,
saveState: state.profilePage.saveState,
savePhotoState: state.profilePage.savePhotoState,
error: state.profilePage.error,
profileImage,
fullName: state.profilePage.profile.name,
username: state.profilePage.profile.username,
userLocation: state.profilePage.profile.country,
education: state.profilePage.profile.levelOfEducation,
socialLinks: state.profilePage.profile.socialLinks,
bio: state.profilePage.profile.bio,
certificates: state.profilePage.profile.certificates,
accountPrivacy: state.profilePage.preferences.accountPrivacy,
visibility: state.profilePage.preferences.visibility || {},
};
};
export default connect(
mapStateToProps,
{
fetchProfile,
saveProfile,
saveProfilePhoto,
deleteProfilePhoto,
openEditableField,
closeEditableField,
savePreferences,
},
)(UserProfile);

View File

@@ -1,22 +0,0 @@
import { applyMiddleware, createStore } from 'redux';
import createSagaMiddleware from 'redux-saga';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import apiClient from './apiClient';
import reducers from './reducers/RootReducer';
import rootSaga from '../sagas/RootSaga';
const loggerMiddleware = createLogger();
const sagaMiddleware = createSagaMiddleware();
const initialState = apiClient.getAuthenticationState();
const store = createStore(
reducers,
initialState,
composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware)),
);
sagaMiddleware.run(rootSaga);
export default store;

View File

@@ -1,80 +1,20 @@
import 'babel-polyfill';
import React, { Component } from 'react';
import React from 'react';
import ReactDOM from 'react-dom';
import { IntlProvider } from 'react-intl';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import SiteFooter from '@edx/frontend-component-footer';
import { fetchUserAccount, UserAccountApiService } from '@edx/frontend-auth';
import { getLocale, getMessages } from './i18n/i18n-loader';
import apiClient from './data/apiClient';
import { handleTrackEvents, identifyUser } from './analytics';
import SiteHeader from './containers/SiteHeader';
import UserProfile from './containers/UserProfile';
import store from './data/store';
import HeaderLogo from '../assets/edx-sm.png';
import FooterLogo from '../assets/edx-footer.png';
import './App.scss';
import NotFoundPage from './components/NotFoundPage';
import configureStore from './config/configureStore';
import apiClient from './config/apiClient';
import { identifyUser } from './analytics';
import { fetchPreferences } from './actions/preferences';
import './index.scss';
class App extends Component {
componentDidMount() {
const { username } = store.getState().authentication;
const userAccountApiService = new UserAccountApiService(apiClient, process.env.LMS_BASE_URL);
store.dispatch(fetchUserAccount(userAccountApiService, username));
store.dispatch(fetchPreferences(username));
}
render() {
return (
<IntlProvider locale={getLocale()} messages={getMessages()}>
<Provider store={store}>
<Router>
<div>
<SiteHeader
logo={HeaderLogo}
logoDestination={process.env.MARKETING_SITE_BASE_URL}
logoAltText={process.env.SITE_NAME}
/>
<main>
<Switch>
<Route path="/u/:username" component={UserProfile} />
<Route path="*" component={NotFoundPage} />
</Switch>
</main>
<SiteFooter
siteName={process.env.SITE_NAME}
siteLogo={FooterLogo}
marketingSiteBaseUrl={process.env.MARKETING_SITE_BASE_URL}
supportUrl={process.env.SUPPORT_URL}
contactUrl={process.env.CONTACT_URL}
openSourceUrl={process.env.OPEN_SOURCE_URL}
termsOfServiceUrl={process.env.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={process.env.PRIVACY_POLICY_URL}
facebookUrl={process.env.FACEBOOK_URL}
twitterUrl={process.env.TWITTER_URL}
youTubeUrl={process.env.YOU_TUBE_URL}
linkedInUrl={process.env.LINKED_IN_URL}
googlePlusUrl={process.env.GOOGLE_PLUS_URL}
redditUrl={process.env.REDDIT_URL}
appleAppStoreUrl={process.env.APPLE_APP_STORE_URL}
googlePlayUrl={process.env.GOOGLE_PLAY_URL}
handleAllTrackEvents={handleTrackEvents}
/>
</div>
</Router>
</Provider>
</IntlProvider>
);
}
}
import App from './components/App';
if (apiClient.ensurePublicOrAuthencationAndCookies(window.location.pathname)) {
ReactDOM.render(<App />, document.getElementById('root'));
const store = configureStore();
ReactDOM.render(<App store={store} />, document.getElementById('root'));
// identify user for future analytics calls
// TODO: Call before each page call.

View File

@@ -22,7 +22,7 @@ $fa-font-path: "~font-awesome/fonts";
opacity: 0;
width: 0;
}
.icon {
font-size: 1.125rem;
}
@@ -40,7 +40,7 @@ $fa-font-path: "~font-awesome/fonts";
.profile-page {
.bg-banner {
height: 12rem;
background-image: url('./assets/dot-pattern-light.png');
background-image: url('../assets/dot-pattern-light.png');
background-repeat: repeat-x;
background-size: auto 85%;
}

View File

@@ -4,46 +4,30 @@ import {
SAVE_PROFILE,
SAVE_PROFILE_PHOTO,
DELETE_PROFILE_PHOTO,
EDITABLE_FIELD_CLOSE,
EDITABLE_FIELD_OPEN,
FIELD_CLOSE,
FIELD_OPEN,
FETCH_PROFILE,
} from '../../actions/profile';
RECEIVE_PREFERENCES,
import {
FETCH_PREFERENCES,
SAVE_PREFERENCES,
} from '../../actions/preferences';
} from '../actions/ProfileActions';
const initialState = {
error: null,
saveState: null,
savePhotoState: null,
savePreferencesState: null,
saveProfileState: null,
currentlyEditingField: null,
profile: {},
preferences: {},
};
// This function returns state based on priority:
// if any are pending > the state is pending
// then, if any are errors > the state is error
// then, if any are complete > the state is complete
// else null
const mergeSaveStates = (statesToMerge) => {
const statePriority = ['pending', 'error', 'complete', null];
statesToMerge.sort((a, b) => statePriority.indexOf(a) - statePriority.indexOf(b));
return statesToMerge[0];
};
const profilePage = (state = initialState, action) => {
switch (action.type) {
case EDITABLE_FIELD_OPEN:
case FIELD_OPEN:
return {
...state,
currentlyEditingField: action.fieldName,
};
case EDITABLE_FIELD_CLOSE:
case FIELD_CLOSE:
// Only close if the field to close is undefined or matches the field that is currently open
if (action.fieldName === state.currentlyEditingField) {
return {
@@ -53,71 +37,40 @@ const profilePage = (state = initialState, action) => {
}
return state;
case FETCH_PREFERENCES.SUCCESS:
case FETCH_PROFILE.SUCCESS:
return {
...state,
profile: action.profile,
};
case RECEIVE_PREFERENCES:
return {
...state,
preferences: defaultsDeep({}, action.preferences, state.preferences),
};
case SAVE_PREFERENCES.BEGIN:
return {
...state,
savePreferencesState: 'pending',
saveState: mergeSaveStates(['pending', state.saveProfileState]),
};
case SAVE_PREFERENCES.SUCCESS:
// defaults deep used because our preferences/state object is multi-dimensional
return {
...state,
savePreferencesState: 'complete',
saveState: mergeSaveStates(['complete', state.saveProfileState]),
};
case SAVE_PREFERENCES.FAILURE:
return {
...state,
savePreferencesState: 'error',
saveState: mergeSaveStates(['error', state.saveProfileState]),
};
case SAVE_PREFERENCES.RESET:
return {
...state,
savePreferencesState: null,
saveState: mergeSaveStates([null, state.saveProfileState]),
error: null,
};
case FETCH_PROFILE.SUCCESS:
return {
...state,
profile: action.payload.profile,
};
case SAVE_PROFILE.BEGIN:
return {
...state,
saveProfileState: 'pending',
saveState: mergeSaveStates(['pending', state.savePreferencesState]),
saveState: 'pending',
error: null,
};
case SAVE_PROFILE.SUCCESS:
return {
...state,
saveProfileState: 'complete',
saveState: mergeSaveStates(['complete', state.savePreferencesState]),
saveState: 'complete',
error: null,
};
case SAVE_PROFILE.FAILURE:
return {
...state,
saveProfileState: 'error',
saveState: mergeSaveStates(['error', state.savePreferencesState]),
saveState: 'error',
error: action.payload.error,
};
case SAVE_PROFILE.RESET:
return {
...state,
saveProfileState: null,
saveState: mergeSaveStates([null, state.savePreferencesState]),
saveState: null,
error: null,
};

View File

@@ -1,5 +1,6 @@
import { combineReducers } from 'redux';
import { userAccount } from '@edx/frontend-auth';
import profilePage from './ProfilePageReducer';
const identityReducer = (state) => {

View File

@@ -4,6 +4,7 @@ import {
FETCH_PROFILE,
fetchProfileBegin,
fetchProfileSuccess,
receivePreferences,
fetchProfileFailure,
fetchProfileReset,
fetchProfile as fetchProfileAction,
@@ -12,7 +13,7 @@ import {
saveProfileSuccess,
saveProfileFailure,
saveProfileReset,
closeEditableField,
closeField,
SAVE_PROFILE_PHOTO,
saveProfilePhotoBegin,
saveProfilePhotoSuccess,
@@ -23,20 +24,7 @@ import {
deleteProfilePhotoSuccess,
deleteProfilePhotoFailure,
deleteProfilePhotoReset,
} from '../actions/profile';
import {
FETCH_PREFERENCES,
fetchPreferencesBegin,
fetchPreferencesSuccess,
fetchPreferencesFailure,
fetchPreferencesReset,
SAVE_PREFERENCES,
savePreferencesBegin,
savePreferencesSuccess,
savePreferencesFailure,
savePreferencesReset,
} from '../actions/preferences';
} from '../actions/ProfileActions';
import * as ProfileApiService from '../services/ProfileApiService';
@@ -61,22 +49,27 @@ export const mapDataForRequest = (props) => {
return state;
};
export function* handleFetchProfile(action) {
const { username } = action.payload;
try {
yield put(fetchProfileBegin());
const profile = yield call(
ProfileApiService.getProfile,
username,
);
const preferences = yield call(
ProfileApiService.getPreferences,
username,
);
profile.certificates = yield call(
ProfileApiService.getCourseCertificates,
username,
);
yield put(fetchProfileSuccess(profile));
yield put(receivePreferences(preferences));
yield put(fetchProfileReset());
} catch (e) {
yield put(fetchProfileFailure(e.message));
@@ -84,19 +77,38 @@ export function* handleFetchProfile(action) {
}
export function* handleSaveProfile(action) {
const { username, userAccountState } = action.payload;
const { username, profileData, preferencesData } = action.payload;
try {
yield put(saveProfileBegin());
const profile = yield call(
ProfileApiService.patchProfile,
username,
userAccountState,
);
const responseData = {};
if (profileData != null) {
responseData.profile = yield call(
ProfileApiService.patchProfile,
username,
profileData,
);
}
if (preferencesData != null) {
responseData.preferences = yield call(
ProfileApiService.patchPreferences,
username,
preferencesData,
);
}
const { profile, preferences } = responseData;
yield put(saveProfileSuccess());
yield put(fetchProfileSuccess(profile));
if (profile != null) {
yield put(fetchProfileSuccess(profile));
}
if (preferences != null) {
yield put(receivePreferences(preferences));
}
yield delay(300);
yield put(closeEditableField(action.payload.fieldName));
yield put(closeField(action.payload.fieldName));
yield delay(300);
yield put(saveProfileReset());
} catch (e) {
@@ -138,37 +150,9 @@ export function* handleDeleteProfilePhoto(action) {
}
}
export function* handleFetchPreferences(action) {
const { username } = action.payload;
try {
yield put(fetchPreferencesBegin());
const userPreferences = yield call(ProfileApiService.getPreferences, username);
yield put(fetchPreferencesSuccess(userPreferences));
yield put(fetchPreferencesReset());
} catch (e) {
yield put(fetchPreferencesFailure(e));
}
}
export function* handleSavePreferences(action) {
const { username, preferences: preferencesToSave } = action.payload;
try {
yield put(savePreferencesBegin());
const preferences = yield call(ProfileApiService.postPreferences, username, preferencesToSave);
yield put(savePreferencesSuccess());
yield put(fetchPreferencesSuccess(preferences));
yield put(savePreferencesReset());
} catch (e) {
yield put(savePreferencesFailure(e));
}
}
export default function* rootSaga() {
yield takeEvery(FETCH_PROFILE.BASE, handleFetchProfile);
yield takeEvery(SAVE_PROFILE.BASE, handleSaveProfile);
yield takeEvery(SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto);
yield takeEvery(DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto);
yield takeEvery(FETCH_PREFERENCES.BASE, handleFetchPreferences);
yield takeEvery(SAVE_PREFERENCES.BASE, handleSavePreferences);
}

View File

@@ -1,7 +1,6 @@
import { takeEvery, put, call, delay } from 'redux-saga/effects';
import * as profileActions from '../actions/profile';
import * as preferencesActions from '../actions/preferences';
import * as profileActions from '../actions/ProfileActions';
jest.mock('../services/ProfileApiService', () => ({
getProfile: jest.fn(),
@@ -18,8 +17,6 @@ import rootSaga, {
handleSaveProfile,
handleSaveProfilePhoto,
handleDeleteProfilePhoto,
handleFetchPreferences,
handleSavePreferences,
} from './RootSaga';
import * as ProfileApiService from '../services/ProfileApiService';
/* eslint-enable import/first */
@@ -29,30 +26,10 @@ describe('RootSaga', () => {
it('should pass actions to the correct sagas', () => {
const gen = rootSaga();
expect(gen.next().value).toEqual(takeEvery(
profileActions.FETCH_PROFILE.BASE,
handleFetchProfile,
));
expect(gen.next().value).toEqual(takeEvery(
profileActions.SAVE_PROFILE.BASE,
handleSaveProfile,
));
expect(gen.next().value).toEqual(takeEvery(
profileActions.SAVE_PROFILE_PHOTO.BASE,
handleSaveProfilePhoto,
));
expect(gen.next().value).toEqual(takeEvery(
profileActions.DELETE_PROFILE_PHOTO.BASE,
handleDeleteProfilePhoto,
));
expect(gen.next().value).toEqual(takeEvery(
preferencesActions.FETCH_PREFERENCES.BASE,
handleFetchPreferences,
));
expect(gen.next().value).toEqual(takeEvery(
preferencesActions.SAVE_PREFERENCES.BASE,
handleSavePreferences,
));
expect(gen.next().value).toEqual(takeEvery(profileActions.FETCH_PROFILE.BASE, handleFetchProfile)); // eslint-disable-line
expect(gen.next().value).toEqual(takeEvery(profileActions.SAVE_PROFILE.BASE, handleSaveProfile)); // eslint-disable-line
expect(gen.next().value).toEqual(takeEvery(profileActions.SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto)); // eslint-disable-line
expect(gen.next().value).toEqual(takeEvery(profileActions.DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto)); // eslint-disable-line
expect(gen.next().value).toBeUndefined();
});
@@ -63,8 +40,11 @@ describe('RootSaga', () => {
const action = profileActions.saveProfile(
'my username',
{
fullName: 'Full Name',
education: 'b',
profileData: {
fullName: 'Full Name',
education: 'b',
},
preferencesData: null,
},
'ze field',
);
@@ -74,13 +54,13 @@ describe('RootSaga', () => {
levelOfEducation: 'b',
};
expect(gen.next().value).toEqual(put(profileActions.saveProfileBegin()));
expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', action.payload.userAccountState));
expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', action.payload.profileData));
// The library would supply the result of the above call
// as the parameter to the NEXT yield. Here:
expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess()));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileSuccess(profile)));
expect(gen.next().value).toEqual(delay(300));
expect(gen.next().value).toEqual(put(profileActions.closeEditableField('ze field')));
expect(gen.next().value).toEqual(put(profileActions.closeField('ze field')));
expect(gen.next().value).toEqual(delay(300));
expect(gen.next().value).toEqual(put(profileActions.saveProfileReset()));
expect(gen.next().value).toBeUndefined();

View File

@@ -1,7 +1,7 @@
// The code in this file is from Segment's website, with the following update:
// - Pulls the segment key from configuration.
// https://segment.com/docs/sources/website/analytics.js/quickstart/
import { configuration } from './config';
import { configuration } from './config/environment';
(function(){

View File

@@ -1,8 +1,8 @@
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
import apiClient from '../data/apiClient';
import { configuration } from '../config';
import apiClient from '../config/apiClient';
import { configuration } from '../config/environment';
import { unflattenAndTransformKeys, flattenAndTransformKeys } from './utils';
const accountsApiBaseUrl = `${configuration.LMS_BASE_URL}/api/user/v1/accounts`;
@@ -18,6 +18,7 @@ const clientServerKeyMap = {
dateJoined: 'date_joined',
languageProficiencies: 'language_proficiencies',
accountPrivacy: 'account_privacy',
userLocation: 'user_location',
};
const serverClientKeyMap = Object.entries(clientServerKeyMap).reduce((acc, [key, value]) => {
acc[value] = key;
@@ -106,7 +107,7 @@ export function getPreferences(username) {
});
}
export function postPreferences(username, preferences) {
export function patchPreferences(username, preferences) {
const url = `${preferencesApiBaseUrl}/${username}`;
// Flatten object for server