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 c84a3c5..0000000 Binary files a/src/assets/dot-pattern-light.png and /dev/null differ diff --git a/src/components/App.jsx b/src/components/App.jsx new file mode 100644 index 0000000..3ad047f --- /dev/null +++ b/src/components/App.jsx @@ -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 ( + + + +
+ +
+ + + + +
+ +
+
+
+
+ ); + } +} + +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