From 0e1a3356aa23383a5cd6fa8adfee5a4aa06bd9f4 Mon Sep 17 00:00:00 2001 From: David Joy Date: Mon, 25 Feb 2019 15:36:24 -0500 Subject: [PATCH] First pass - realigning/simplifying/refactoring (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/actions/{profile.js => ProfileActions.js} | 32 +++-- ...profile.test.js => ProfileActions.test.js} | 35 +++--- src/actions/preferences.js | 51 -------- src/analytics.js | 2 +- src/assets/dot-pattern-light.png | Bin 38914 -> 0 bytes src/components/App.jsx | 85 +++++++++++++ .../index.jsx => ProfilePage.jsx} | 119 +++++++++++------- .../{UserProfile => ProfilePage}/Bio.jsx | 0 .../Education.jsx | 0 .../{UserProfile => ProfilePage}/FullName.jsx | 0 .../MyCertificates.jsx | 0 .../ProfileAvatar.jsx | 0 .../SocialLinks.jsx | 0 .../UserLocation.jsx | 0 .../elements/AsyncActionButton.jsx | 0 .../elements/EditButton.jsx | 0 .../elements/EditControls.jsx | 0 .../elements/EditableItemHeader.jsx | 0 .../elements/EmptyContent.jsx | 0 .../elements/SwitchContent.jsx | 0 .../elements/TransitionReplace.jsx | 0 .../elements/Visibility.jsx | 0 .../index.jsx => components/SiteHeader.jsx} | 0 src/{data => config}/apiClient.js | 4 +- src/config/configureStore.dev.js | 25 ++++ src/config/configureStore.js | 9 ++ src/config/configureStore.prod.js | 22 ++++ src/config/{index.js => environment.js} | 7 +- src/containers/UserProfile/index.jsx | 49 -------- src/data/store.js | 22 ---- src/index.jsx | 78 ++---------- src/{App.scss => index.scss} | 4 +- src/{data => }/reducers/ProfilePageReducer.js | 81 +++--------- src/{data => }/reducers/RootReducer.js | 1 + src/sagas/RootSaga.js | 88 ++++++------- src/sagas/RootSaga.test.js | 44 ++----- src/segment.js | 2 +- src/services/ProfileApiService.js | 7 +- 38 files changed, 346 insertions(+), 421 deletions(-) rename src/actions/{profile.js => ProfileActions.js} (78%) rename src/actions/{profile.test.js => ProfileActions.test.js} (88%) delete mode 100644 src/actions/preferences.js delete mode 100644 src/assets/dot-pattern-light.png create mode 100644 src/components/App.jsx rename src/components/{UserProfile/index.jsx => ProfilePage.jsx} (74%) rename src/components/{UserProfile => ProfilePage}/Bio.jsx (100%) rename src/components/{UserProfile => ProfilePage}/Education.jsx (100%) rename src/components/{UserProfile => ProfilePage}/FullName.jsx (100%) rename src/components/{UserProfile => ProfilePage}/MyCertificates.jsx (100%) rename src/components/{UserProfile => ProfilePage}/ProfileAvatar.jsx (100%) rename src/components/{UserProfile => ProfilePage}/SocialLinks.jsx (100%) rename src/components/{UserProfile => ProfilePage}/UserLocation.jsx (100%) rename src/components/{UserProfile => ProfilePage}/elements/AsyncActionButton.jsx (100%) rename src/components/{UserProfile => ProfilePage}/elements/EditButton.jsx (100%) rename src/components/{UserProfile => ProfilePage}/elements/EditControls.jsx (100%) rename src/components/{UserProfile => ProfilePage}/elements/EditableItemHeader.jsx (100%) rename src/components/{UserProfile => ProfilePage}/elements/EmptyContent.jsx (100%) rename src/components/{UserProfile => ProfilePage}/elements/SwitchContent.jsx (100%) rename src/components/{UserProfile => ProfilePage}/elements/TransitionReplace.jsx (100%) rename src/components/{UserProfile => ProfilePage}/elements/Visibility.jsx (100%) rename src/{containers/SiteHeader/index.jsx => components/SiteHeader.jsx} (100%) rename src/{data => config}/apiClient.js (92%) create mode 100644 src/config/configureStore.dev.js create mode 100644 src/config/configureStore.js create mode 100644 src/config/configureStore.prod.js rename src/config/{index.js => environment.js} (85%) delete mode 100644 src/containers/UserProfile/index.jsx delete mode 100755 src/data/store.js rename src/{App.scss => index.scss} (96%) rename src/{data => }/reducers/ProfilePageReducer.js (54%) rename src/{data => }/reducers/RootReducer.js (99%) diff --git a/src/actions/profile.js b/src/actions/ProfileActions.js similarity index 78% rename from src/actions/profile.js rename to src/actions/ProfileActions.js index b7538b3..a051936 100644 --- a/src/actions/profile.js +++ b/src/actions/ProfileActions.js @@ -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, }, }); diff --git a/src/actions/profile.test.js b/src/actions/ProfileActions.test.js similarity index 88% rename from src/actions/profile.test.js rename to src/actions/ProfileActions.test.js index 25b3093..a21917f 100644 --- a/src/actions/profile.test.js +++ b/src/actions/ProfileActions.test.js @@ -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); }); }); diff --git a/src/actions/preferences.js b/src/actions/preferences.js deleted file mode 100644 index fef10e7..0000000 --- a/src/actions/preferences.js +++ /dev/null @@ -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 }, -}); diff --git a/src/analytics.js b/src/analytics.js index 24a4d57..389ed16 100755 --- a/src/analytics.js +++ b/src/analytics.js @@ -1,4 +1,4 @@ -import apiClient from './data/apiClient'; +import apiClient from './config/apiClient'; const handleTrackEvents = (eventName, properties) => { // Simply forward track events to Segment diff --git a/src/assets/dot-pattern-light.png b/src/assets/dot-pattern-light.png deleted file mode 100644 index c84a3c52a385bf62a54a7cf948d8cf6c7d3edd31..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38914 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 + + +
+ +
+ + + + +
+ +
+
+
+ + ); + } +} + +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); diff --git a/src/components/UserProfile/index.jsx b/src/components/ProfilePage.jsx similarity index 74% rename from src/components/UserProfile/index.jsx rename to src/components/ProfilePage.jsx index 32aa4d1..94d38a5 100644 --- a/src/components/UserProfile/index.jsx +++ b/src/components/ProfilePage.jsx @@ -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); diff --git a/src/components/UserProfile/Bio.jsx b/src/components/ProfilePage/Bio.jsx similarity index 100% rename from src/components/UserProfile/Bio.jsx rename to src/components/ProfilePage/Bio.jsx diff --git a/src/components/UserProfile/Education.jsx b/src/components/ProfilePage/Education.jsx similarity index 100% rename from src/components/UserProfile/Education.jsx rename to src/components/ProfilePage/Education.jsx diff --git a/src/components/UserProfile/FullName.jsx b/src/components/ProfilePage/FullName.jsx similarity index 100% rename from src/components/UserProfile/FullName.jsx rename to src/components/ProfilePage/FullName.jsx diff --git a/src/components/UserProfile/MyCertificates.jsx b/src/components/ProfilePage/MyCertificates.jsx similarity index 100% rename from src/components/UserProfile/MyCertificates.jsx rename to src/components/ProfilePage/MyCertificates.jsx diff --git a/src/components/UserProfile/ProfileAvatar.jsx b/src/components/ProfilePage/ProfileAvatar.jsx similarity index 100% rename from src/components/UserProfile/ProfileAvatar.jsx rename to src/components/ProfilePage/ProfileAvatar.jsx diff --git a/src/components/UserProfile/SocialLinks.jsx b/src/components/ProfilePage/SocialLinks.jsx similarity index 100% rename from src/components/UserProfile/SocialLinks.jsx rename to src/components/ProfilePage/SocialLinks.jsx diff --git a/src/components/UserProfile/UserLocation.jsx b/src/components/ProfilePage/UserLocation.jsx similarity index 100% rename from src/components/UserProfile/UserLocation.jsx rename to src/components/ProfilePage/UserLocation.jsx diff --git a/src/components/UserProfile/elements/AsyncActionButton.jsx b/src/components/ProfilePage/elements/AsyncActionButton.jsx similarity index 100% rename from src/components/UserProfile/elements/AsyncActionButton.jsx rename to src/components/ProfilePage/elements/AsyncActionButton.jsx diff --git a/src/components/UserProfile/elements/EditButton.jsx b/src/components/ProfilePage/elements/EditButton.jsx similarity index 100% rename from src/components/UserProfile/elements/EditButton.jsx rename to src/components/ProfilePage/elements/EditButton.jsx diff --git a/src/components/UserProfile/elements/EditControls.jsx b/src/components/ProfilePage/elements/EditControls.jsx similarity index 100% rename from src/components/UserProfile/elements/EditControls.jsx rename to src/components/ProfilePage/elements/EditControls.jsx diff --git a/src/components/UserProfile/elements/EditableItemHeader.jsx b/src/components/ProfilePage/elements/EditableItemHeader.jsx similarity index 100% rename from src/components/UserProfile/elements/EditableItemHeader.jsx rename to src/components/ProfilePage/elements/EditableItemHeader.jsx diff --git a/src/components/UserProfile/elements/EmptyContent.jsx b/src/components/ProfilePage/elements/EmptyContent.jsx similarity index 100% rename from src/components/UserProfile/elements/EmptyContent.jsx rename to src/components/ProfilePage/elements/EmptyContent.jsx diff --git a/src/components/UserProfile/elements/SwitchContent.jsx b/src/components/ProfilePage/elements/SwitchContent.jsx similarity index 100% rename from src/components/UserProfile/elements/SwitchContent.jsx rename to src/components/ProfilePage/elements/SwitchContent.jsx diff --git a/src/components/UserProfile/elements/TransitionReplace.jsx b/src/components/ProfilePage/elements/TransitionReplace.jsx similarity index 100% rename from src/components/UserProfile/elements/TransitionReplace.jsx rename to src/components/ProfilePage/elements/TransitionReplace.jsx diff --git a/src/components/UserProfile/elements/Visibility.jsx b/src/components/ProfilePage/elements/Visibility.jsx similarity index 100% rename from src/components/UserProfile/elements/Visibility.jsx rename to src/components/ProfilePage/elements/Visibility.jsx diff --git a/src/containers/SiteHeader/index.jsx b/src/components/SiteHeader.jsx similarity index 100% rename from src/containers/SiteHeader/index.jsx rename to src/components/SiteHeader.jsx diff --git a/src/data/apiClient.js b/src/config/apiClient.js similarity index 92% rename from src/data/apiClient.js rename to src/config/apiClient.js index 4af0b6a..1a145f9 100644 --- a/src/data/apiClient.js +++ b/src/config/apiClient.js @@ -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; diff --git a/src/config/configureStore.dev.js b/src/config/configureStore.dev.js new file mode 100644 index 0000000..790b605 --- /dev/null +++ b/src/config/configureStore.dev.js @@ -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; +} diff --git a/src/config/configureStore.js b/src/config/configureStore.js new file mode 100644 index 0000000..d867a3c --- /dev/null +++ b/src/config/configureStore.js @@ -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; +} diff --git a/src/config/configureStore.prod.js b/src/config/configureStore.prod.js new file mode 100644 index 0000000..4b85902 --- /dev/null +++ b/src/config/configureStore.prod.js @@ -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; +} diff --git a/src/config/index.js b/src/config/environment.js similarity index 85% rename from src/config/index.js rename to src/config/environment.js index 7dd296c..e43e226 100644 --- a/src/config/index.js +++ b/src/config/environment.js @@ -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 = {}; diff --git a/src/containers/UserProfile/index.jsx b/src/containers/UserProfile/index.jsx deleted file mode 100644 index e118104..0000000 --- a/src/containers/UserProfile/index.jsx +++ /dev/null @@ -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); diff --git a/src/data/store.js b/src/data/store.js deleted file mode 100755 index 43523b0..0000000 --- a/src/data/store.js +++ /dev/null @@ -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; diff --git a/src/index.jsx b/src/index.jsx index b30686e..3106a48 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -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 ( - - - -
- -
- - - - -
- -
-
-
-
- ); - } -} +import App from './components/App'; if (apiClient.ensurePublicOrAuthencationAndCookies(window.location.pathname)) { - ReactDOM.render(, document.getElementById('root')); + const store = configureStore(); + + ReactDOM.render(, document.getElementById('root')); // identify user for future analytics calls // TODO: Call before each page call. diff --git a/src/App.scss b/src/index.scss similarity index 96% rename from src/App.scss rename to src/index.scss index bd7f0a7..f97a417 100755 --- a/src/App.scss +++ b/src/index.scss @@ -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%; } diff --git a/src/data/reducers/ProfilePageReducer.js b/src/reducers/ProfilePageReducer.js similarity index 54% rename from src/data/reducers/ProfilePageReducer.js rename to src/reducers/ProfilePageReducer.js index e439284..a35e205 100644 --- a/src/data/reducers/ProfilePageReducer.js +++ b/src/reducers/ProfilePageReducer.js @@ -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, }; diff --git a/src/data/reducers/RootReducer.js b/src/reducers/RootReducer.js similarity index 99% rename from src/data/reducers/RootReducer.js rename to src/reducers/RootReducer.js index f7365b9..bca1391 100755 --- a/src/data/reducers/RootReducer.js +++ b/src/reducers/RootReducer.js @@ -1,5 +1,6 @@ import { combineReducers } from 'redux'; import { userAccount } from '@edx/frontend-auth'; + import profilePage from './ProfilePageReducer'; const identityReducer = (state) => { diff --git a/src/sagas/RootSaga.js b/src/sagas/RootSaga.js index 808ec99..75211f1 100644 --- a/src/sagas/RootSaga.js +++ b/src/sagas/RootSaga.js @@ -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); } diff --git a/src/sagas/RootSaga.test.js b/src/sagas/RootSaga.test.js index bf72f5e..fac245a 100644 --- a/src/sagas/RootSaga.test.js +++ b/src/sagas/RootSaga.test.js @@ -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(); diff --git a/src/segment.js b/src/segment.js index 1206ed6..2dfb2e9 100644 --- a/src/segment.js +++ b/src/segment.js @@ -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(){ diff --git a/src/services/ProfileApiService.js b/src/services/ProfileApiService.js index f9831fd..b7e0d18 100644 --- a/src/services/ProfileApiService.js +++ b/src/services/ProfileApiService.js @@ -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