Organize application according to semantic modules. (#153)

This commit is contained in:
David Joy
2019-04-16 11:46:20 -04:00
committed by GitHub
parent 14023dbf32
commit d009d5ce6c
85 changed files with 725 additions and 555 deletions

View File

@@ -6,7 +6,7 @@
"error",
{
"devDependencies": [
"config/*.js",
"webpack/*.js",
"**/*.test.jsx",
"**/*.test.js"
]

View File

@@ -7,7 +7,7 @@ Dockerfile
Makefile
npm-debug.log
config
webpack
coverage
node_modules
public

View File

@@ -4,4 +4,3 @@
nick: acct
oeps: {}
owner: edx/arch-team
track-pulls: true

60
package-lock.json generated
View File

@@ -5490,11 +5490,6 @@
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24="
},
"bootstrap": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.2.1.tgz",
"integrity": "sha512-tt/7vIv3Gm2mnd/WeDx36nfGGHleil0Wg8IeB7eMrVkY0fZ5iTaBisSh8oNANc2IBsCc6vCgCNTIM/IEN0+50Q=="
},
"boxen": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz",
@@ -7932,7 +7927,8 @@
"decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
"dev": true
},
"decompress": {
"version": "4.2.0",
@@ -10399,7 +10395,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@@ -10420,12 +10417,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -10440,17 +10439,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@@ -10567,7 +10569,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@@ -10579,6 +10582,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -10593,6 +10597,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -10600,12 +10605,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@@ -10624,6 +10631,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -10704,7 +10712,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@@ -10716,6 +10725,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@@ -10801,7 +10811,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -10837,6 +10848,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -10856,6 +10868,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -10899,12 +10912,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},
@@ -14041,6 +14056,11 @@
"integrity": "sha1-0uPuv/DZ05rVD1y9G1KnvOa7YRs=",
"dev": true
},
"lodash.pick": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz",
"integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM="
},
"lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
@@ -18384,6 +18404,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz",
"integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==",
"dev": true,
"requires": {
"decode-uri-component": "^0.2.0",
"object-assign": "^4.1.0",
@@ -25227,11 +25248,6 @@
"iconv-lite": "0.4.24"
}
},
"whatwg-fetch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz",
"integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng=="
},
"whatwg-mimetype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz",

View File

@@ -7,13 +7,13 @@
"url": "git+https://github.com/edx/frontend-app-profile.git"
},
"scripts": {
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=config/webpack.prod.config.js",
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=webpack/webpack.prod.config.js",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"i18n_extract": "BABEL_ENV=i18n babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "eslint --ext .js --ext .jsx .",
"precommit": "npm run lint",
"start": "NODE_ENV=development BABEL_ENV=development webpack-dev-server --config=config/webpack.dev.config.js --progress",
"start": "NODE_ENV=development BABEL_ENV=development webpack-dev-server --config=webpack/webpack.dev.config.js --progress",
"test": "jest --coverage --passWithNoTests",
"travis-deploy-once": "travis-deploy-once"
},
@@ -38,7 +38,6 @@
"@fortawesome/free-solid-svg-icons": "^5.7.1",
"@fortawesome/react-fontawesome": "^0.1.4",
"babel-polyfill": "^6.26.0",
"bootstrap": "^4.2.1",
"classnames": "^2.2.6",
"connected-react-router": "^5.0.1",
"email-prop-type": "^1.1.5",
@@ -50,10 +49,10 @@
"iso-countries-languages": "^0.2.1",
"lodash.camelcase": "^4.3.0",
"lodash.get": "^4.4.2",
"lodash.pick": "^4.4.0",
"lodash.snakecase": "^4.1.1",
"newrelic": "^5.5.0",
"prop-types": "^15.5.10",
"query-string": "^5.1.1",
"react": "^16.8.3",
"react-dom": "^16.8.3",
"react-intl": "^2.8.0",
@@ -68,8 +67,7 @@
"redux-thunk": "^2.2.0",
"reselect": "^4.0.0",
"universal-cookie": "^3.1.0",
"webpack-rtl-plugin": "^2.0.0",
"whatwg-fetch": "^2.0.3"
"webpack-rtl-plugin": "^2.0.0"
},
"devDependencies": {
"@svgr/webpack": "^4.1.0",

View File

@@ -1,32 +0,0 @@
/**
* Helper class to save time when writing out action types for asynchronous methods. Also helps
* ensure that actions are namespaced.
*
* TODO: Put somewhere common to it can be used by other MFEs.
*/
export default class AsyncActionType {
constructor(topic, name) {
this.topic = topic;
this.name = name;
}
get BASE() {
return `${this.topic}__${this.name}`;
}
get BEGIN() {
return `${this.topic}__${this.name}__BEGIN`;
}
get SUCCESS() {
return `${this.topic}__${this.name}__SUCCESS`;
}
get FAILURE() {
return `${this.topic}__${this.name}__FAILURE`;
}
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
}

View File

@@ -1,13 +0,0 @@
import AsyncActionType from './AsyncActionType';
describe('AsyncActionType', () => {
it('should return well formatted action strings', () => {
const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE');
expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE');
expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN');
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
});
});

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

11
src/common/actions.js Normal file
View File

@@ -0,0 +1,11 @@
import { fetchUserAccount as _fetchUserAccount, UserAccountApiService } from '@edx/frontend-auth';
let userAccountApiService = null;
export function configureUserAccountApiService(configuration, apiClient) {
userAccountApiService = new UserAccountApiService(apiClient, configuration.LMS_BASE_URL);
}
export function fetchUserAccount(username) {
return _fetchUserAccount(userAccountApiService, username);
}

View File

@@ -1,11 +1,8 @@
import React from 'react';
import Banner from './Banner';
function PageLoading() {
return (
<div>
<Banner />
<div
className="d-flex justify-content-center align-items-center flex-column"
style={{

10
src/common/index.js Normal file
View File

@@ -0,0 +1,10 @@
import * as utils from './utils';
import PageLoading from './components/PageLoading';
import { configureUserAccountApiService, fetchUserAccount } from './actions';
export {
PageLoading,
utils,
configureUserAccountApiService,
fetchUserAccount,
};

View File

@@ -46,3 +46,36 @@ export function keepKeys(data, whitelist) {
});
return result;
}
/**
* Helper class to save time when writing out action types for asynchronous methods. Also helps
* ensure that actions are namespaced.
*
* TODO: Put somewhere common to it can be used by other MFEs.
*/
export class AsyncActionType {
constructor(topic, name) {
this.topic = topic;
this.name = name;
}
get BASE() {
return `${this.topic}__${this.name}`;
}
get BEGIN() {
return `${this.topic}__${this.name}__BEGIN`;
}
get SUCCESS() {
return `${this.topic}__${this.name}__SUCCESS`;
}
get FAILURE() {
return `${this.topic}__${this.name}__FAILURE`;
}
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
}

View File

@@ -1,4 +1,4 @@
import { modifyObjectKeys, camelCaseObject, snakeCaseObject, convertKeyNames, keepKeys } from './utils';
import { AsyncActionType, modifyObjectKeys, camelCaseObject, snakeCaseObject, convertKeyNames, keepKeys } from './utils';
describe('modifyObjectKeys', () => {
it('should use the provided modify function to change all keys in and object and its children', () => {
@@ -102,4 +102,17 @@ describe('keepKeys', () => {
8: 'sneaky',
});
});
describe('AsyncActionType', () => {
it('should return well formatted action strings', () => {
const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE');
expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE');
expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN');
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
});
});
});

View File

@@ -1,67 +1,167 @@
import React, { Component } from 'react';
import { connect, Provider } from 'react-redux';
import PropTypes from 'prop-types';
import { IntlProvider } from 'react-intl';
import { IntlProvider, injectIntl, intlShape } from 'react-intl';
import { Route, Switch } from 'react-router-dom';
import { ConnectedRouter } from 'connected-react-router';
import { sendTrackEvent } from '@edx/frontend-analytics';
import SiteHeader from '@edx/frontend-component-site-header';
import SiteFooter from '@edx/frontend-component-footer';
import { fetchUserAccount, UserAccountApiService } from '@edx/frontend-auth';
import apiClient from '../config/apiClient';
import { getLocale, getMessages } from '@edx/frontend-i18n'; // eslint-disable-line
import SiteHeader from './common/SiteHeader';
import ConnectedProfilePage from './ProfilePage';
import FooterLogo from '../../assets/edx-footer.png';
import { PageLoading, fetchUserAccount } from '../common';
import { ConnectedProfilePage } from '../profile';
import FooterLogo from '../assets/edx-footer.png';
import HeaderLogo from '../assets/logo.svg';
import ErrorPage from './ErrorPage';
import NotFoundPage from './NotFoundPage';
import PageLoading from './common/PageLoading';
import messages from './App.messages';
function PageContent({
ready,
configuration,
username,
avatar,
intl,
}) {
if (!ready) {
return <PageLoading />;
}
const mainMenu = [
{
type: 'item',
href: `${process.env.MARKETING_SITE_BASE_URL}/course`,
content: intl.formatMessage(messages['siteheader.links.courses']),
},
{
type: 'item',
href: `${process.env.MARKETING_SITE_BASE_URL}/course?program=all`,
content: intl.formatMessage(messages['siteheader.links.programs']),
},
{
type: 'item',
href: `${process.env.MARKETING_SITE_BASE_URL}/schools-partners`,
content: intl.formatMessage(messages['siteheader.links.schools']),
},
];
const userMenu = [
{
type: 'item',
href: `${process.env.LMS_BASE_URL}`,
content: intl.formatMessage(messages['siteheader.user.menu.dashboard']),
},
{
type: 'item',
href: `${process.env.BASE_URL}/u/${username}`,
content: intl.formatMessage(messages['siteheader.user.menu.profile']),
},
{
type: 'item',
href: `${process.env.LMS_BASE_URL}/account/settings`,
content: intl.formatMessage(messages['siteheader.user.menu.account.settings']),
},
{
type: 'item',
href: process.env.LOGOUT_URL,
content: intl.formatMessage(messages['siteheader.user.menu.logout']),
},
];
const loggedOutItems = [
{
type: 'item',
href: `${process.env.LMS_BASE_URL}/login`,
content: intl.formatMessage(messages['siteheader.user.menu.login']),
},
{
type: 'item',
href: `${process.env.LMS_BASE_URL}/register`,
content: intl.formatMessage(messages['siteheader.user.menu.register']),
},
];
return (
<div>
<SiteHeader
logo={HeaderLogo}
loggedIn
username={username}
avatar={avatar}
logoAltText={configuration.SITE_NAME}
logoDestination={configuration.MARKETING_SITE_BASE_URL}
mainMenu={mainMenu}
userMenu={userMenu}
loggedOutItems={loggedOutItems}
/>
<main>
<Switch>
<Route path="/u/:username" component={ConnectedProfilePage} />
<Route path="/error" component={ErrorPage} />
<Route path="/notfound" component={NotFoundPage} />
<Route path="*" component={NotFoundPage} />
</Switch>
</main>
<SiteFooter
siteName={configuration.SITE_NAME}
siteLogo={FooterLogo}
marketingSiteBaseUrl={configuration.MARKETING_SITE_BASE_URL}
supportUrl={configuration.SUPPORT_URL}
contactUrl={configuration.CONTACT_URL}
openSourceUrl={configuration.OPEN_SOURCE_URL}
termsOfServiceUrl={configuration.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={configuration.PRIVACY_POLICY_URL}
facebookUrl={configuration.FACEBOOK_URL}
twitterUrl={configuration.TWITTER_URL}
youTubeUrl={configuration.YOU_TUBE_URL}
linkedInUrl={configuration.LINKED_IN_URL}
googlePlusUrl={configuration.GOOGLE_PLUS_URL}
redditUrl={configuration.REDDIT_URL}
appleAppStoreUrl={configuration.APPLE_APP_STORE_URL}
googlePlayUrl={configuration.GOOGLE_PLAY_URL}
handleAllTrackEvents={sendTrackEvent}
/>
</div>
);
}
PageContent.propTypes = {
username: PropTypes.string.isRequired,
avatar: PropTypes.string,
ready: PropTypes.bool,
configuration: PropTypes.shape({
SITE_NAME: PropTypes.string.isRequired,
MARKETING_SITE_BASE_URL: PropTypes.string.isRequired,
SUPPORT_URL: PropTypes.string.isRequired,
CONTACT_URL: PropTypes.string.isRequired,
OPEN_SOURCE_URL: PropTypes.string.isRequired,
TERMS_OF_SERVICE_URL: PropTypes.string.isRequired,
PRIVACY_POLICY_URL: PropTypes.string.isRequired,
FACEBOOK_URL: PropTypes.string.isRequired,
TWITTER_URL: PropTypes.string.isRequired,
YOU_TUBE_URL: PropTypes.string.isRequired,
LINKED_IN_URL: PropTypes.string.isRequired,
GOOGLE_PLUS_URL: PropTypes.string.isRequired,
REDDIT_URL: PropTypes.string.isRequired,
APPLE_APP_STORE_URL: PropTypes.string.isRequired,
GOOGLE_PLAY_URL: PropTypes.string.isRequired,
}).isRequired,
intl: intlShape.isRequired,
};
PageContent.defaultProps = {
ready: false,
avatar: null,
};
const IntlPageContent = injectIntl(PageContent);
class App extends Component {
componentDidMount() {
const { username } = this.props;
const userAccountApiService = new UserAccountApiService(apiClient, process.env.LMS_BASE_URL);
this.props.fetchUserAccount(userAccountApiService, username);
}
renderContent() {
if (!this.props.ready) {
return <PageLoading />;
}
return (
<div>
<SiteHeader />
<main>
<Switch>
<Route path="/u/:username" component={ConnectedProfilePage} />
<Route path="/error" component={ErrorPage} />
<Route path="/notfound" component={NotFoundPage} />
<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={sendTrackEvent}
/>
</div>
);
this.props.fetchUserAccount(username);
}
render() {
@@ -69,7 +169,12 @@ class App extends Component {
<IntlProvider locale={getLocale()} messages={getMessages()}>
<Provider store={this.props.store}>
<ConnectedRouter history={this.props.history}>
{this.renderContent()}
<IntlPageContent
ready={this.props.ready}
configuration={this.props.configuration}
username={this.props.username}
avatar={this.props.avatar}
/>
</ConnectedRouter>
</Provider>
</IntlProvider>
@@ -80,13 +185,32 @@ class App extends Component {
App.propTypes = {
fetchUserAccount: PropTypes.func.isRequired,
username: PropTypes.string.isRequired,
avatar: PropTypes.string,
store: PropTypes.object.isRequired, // eslint-disable-line
history: PropTypes.object.isRequired, // eslint-disable-line
ready: PropTypes.bool,
configuration: PropTypes.shape({
SITE_NAME: PropTypes.string.isRequired,
MARKETING_SITE_BASE_URL: PropTypes.string.isRequired,
SUPPORT_URL: PropTypes.string.isRequired,
CONTACT_URL: PropTypes.string.isRequired,
OPEN_SOURCE_URL: PropTypes.string.isRequired,
TERMS_OF_SERVICE_URL: PropTypes.string.isRequired,
PRIVACY_POLICY_URL: PropTypes.string.isRequired,
FACEBOOK_URL: PropTypes.string.isRequired,
TWITTER_URL: PropTypes.string.isRequired,
YOU_TUBE_URL: PropTypes.string.isRequired,
LINKED_IN_URL: PropTypes.string.isRequired,
GOOGLE_PLUS_URL: PropTypes.string.isRequired,
REDDIT_URL: PropTypes.string.isRequired,
APPLE_APP_STORE_URL: PropTypes.string.isRequired,
GOOGLE_PLAY_URL: PropTypes.string.isRequired,
}).isRequired,
};
App.defaultProps = {
ready: false,
avatar: null,
};
const mapStateToProps = state => ({
@@ -94,6 +218,10 @@ const mapStateToProps = state => ({
// An error means that we tried to load the user account and failed,
// which also means we're ready to display something.
ready: state.userAccount.loaded || state.userAccount.error != null,
configuration: state.configuration,
avatar: state.userAccount.profileImage.hasImage
? state.userAccount.profileImage.imageUrlMedium
: null,
});
export default connect(

View File

@@ -1,46 +1,51 @@
import React, { Component } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button } from '@edx/paragon';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import apiClient from '../config/apiClient';
export default class ErrorPage extends Component {
componentDidMount() {}
render() {
const { username } = apiClient.getAuthenticationState().authentication;
return (
<div className="container-fluid py-5 justify-content-center align-items-start text-center">
<div className="row">
<div className="col">
<p className="my-0 py-5 text-muted">
<FormattedMessage
id="profile.error.message.text"
defaultMessage="An unexpected error occurred. Please click the button below to return to your profile and try again."
description="error message when an unexpected error occurs"
/>
</p>
</div>
</div>
<div className="row">
<div className="col">
<Link to={`/u/${username}`}>
<Button
buttonType="primary"
label={
<FormattedMessage
id="profile.error.button.text"
defaultMessage="Return to Your Profile"
description="text for button that navigates back to your profile page after an error has occured"
/>
}
/>
</Link>
</div>
function ErrorPage({ username }) {
return (
<div className="container-fluid py-5 justify-content-center align-items-start text-center">
<div className="row">
<div className="col">
<p className="my-0 py-5 text-muted">
<FormattedMessage
id="profile.error.message.text"
defaultMessage="An unexpected error occurred. Please click the button below to return to your profile and try again."
description="error message when an unexpected error occurs"
/>
</p>
</div>
</div>
);
}
<div className="row">
<div className="col">
<Link to={`/u/${username}`}>
<Button
buttonType="primary"
label={
<FormattedMessage
id="profile.error.button.text"
defaultMessage="Return to Your Profile"
description="text for button that navigates back to your profile page after an error has occured"
/>
}
/>
</Link>
</div>
</div>
</div>
);
}
ErrorPage.propTypes = {
username: PropTypes.string.isRequired,
};
export default connect(
state => ({
username: state.authentication.username,
}),
{},
)(ErrorPage);

View File

@@ -1,23 +1,16 @@
import React, { Component } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class NotFoundPage extends Component {
componentDidMount() {}
render() {
return (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p
className="my-0 py-5 text-muted"
style={{ maxWidth: '32em' }}
>
<FormattedMessage
id="profile.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="error message when a page does not exist"
/>
</p>
</div>
);
}
export default function NotFoundPage() {
return (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
<FormattedMessage
id="profile.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="error message when a page does not exist"
/>
</p>
</div>
);
}

View File

@@ -1,71 +0,0 @@
import { connect } from 'react-redux';
import SiteHeader from '@edx/frontend-component-site-header';
import { injectIntl } from '@edx/frontend-i18n'; // eslint-disable-line
import messages from './SiteHeader.messages';
import Logo from '../../assets/logo.svg';
const mapStateToProps = (state, { intl }) => ({
logo: Logo,
logoDestination: process.env.MARKETING_SITE_BASE_URL,
logoAltText: 'edX',
mainMenu: [
{
type: 'item',
href: `${process.env.MARKETING_SITE_BASE_URL}/course`,
content: intl.formatMessage(messages['siteheader.links.courses']),
},
{
type: 'item',
href: `${process.env.MARKETING_SITE_BASE_URL}/course?program=all`,
content: intl.formatMessage(messages['siteheader.links.programs']),
},
{
type: 'item',
href: `${process.env.MARKETING_SITE_BASE_URL}/schools-partners`,
content: intl.formatMessage(messages['siteheader.links.schools']),
},
],
loggedIn: true,
username: state.userAccount.username,
avatar: state.userAccount.profileImage.hasImage ?
state.userAccount.profileImage.imageUrlMedium :
null,
userMenu: [
{
type: 'item',
href: `${process.env.LMS_BASE_URL}`,
content: intl.formatMessage(messages['siteheader.user.menu.dashboard']),
},
{
type: 'item',
href: `${process.env.BASE_URL}/u/${state.userAccount.username}`,
content: intl.formatMessage(messages['siteheader.user.menu.profile']),
},
{
type: 'item',
href: `${process.env.LMS_BASE_URL}/account/settings`,
content: intl.formatMessage(messages['siteheader.user.menu.account.settings']),
},
{
type: 'item',
href: process.env.LOGOUT_URL,
content: intl.formatMessage(messages['siteheader.user.menu.logout']),
},
],
loggedOutItems: [
{
type: 'item',
href: `${process.env.LMS_BASE_URL}/login`,
content: intl.formatMessage(messages['siteheader.user.menu.login']),
},
{
type: 'item',
href: `${process.env.LMS_BASE_URL}/register`,
content: intl.formatMessage(messages['siteheader.user.menu.register']),
},
],
});
export default injectIntl(connect(mapStateToProps)(SiteHeader));

View File

@@ -1,12 +0,0 @@
import { configureAnalytics, initializeSegment } from '@edx/frontend-analytics';
import LoggingService from '@edx/frontend-logging';
import apiClient from '../config/apiClient';
import { configuration } from '../config/environment';
initializeSegment(configuration.SEGMENT_KEY);
configureAnalytics({
loggingService: LoggingService,
authApiClient: apiClient,
analyticsApiBaseUrl: configuration.LMS_BASE_URL,
});

View File

@@ -1,17 +0,0 @@
import { getAuthenticatedAPIClient } from '@edx/frontend-auth';
import { configuration } from './environment';
const apiClient = getAuthenticatedAPIClient({
appBaseUrl: configuration.BASE_URL,
authBaseUrl: process.env.LMS_BASE_URL,
loginUrl: configuration.LOGIN_URL,
logoutUrl: configuration.LOGOUT_URL,
csrfTokenApiPath: process.env.CSRF_TOKEN_API_PATH,
refreshAccessTokenEndpoint: configuration.REFRESH_ACCESS_TOKEN_ENDPOINT,
accessTokenCookieName: configuration.ACCESS_TOKEN_COOKIE_NAME,
userInfoCookieName: process.env.USER_INFO_COOKIE_NAME,
csrfCookieName: configuration.CSRF_COOKIE_NAME,
});
export default apiClient;

View File

@@ -1,9 +0,0 @@
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

@@ -1,13 +0,0 @@
const EDUCATION_LEVELS = [
'p',
'm',
'b',
'a',
'hs',
'jhs',
'el',
'none',
'o',
];
export default EDUCATION_LEVELS;

View File

@@ -1,34 +0,0 @@
const SOCIAL = {
linkedin: {
title: 'LinkedIn',
},
twitter: {
title: 'Twitter',
},
facebook: {
title: 'Facebook',
},
};
const getSocialLinks = (socialLinksState) => {
const socialLinks = {};
if (socialLinksState) {
socialLinksState.forEach((link) => {
const socialLinkUrl = link.socialLink;
if (socialLinkUrl) {
socialLinks[link.platform] = {
...SOCIAL[link.platform],
url: link.socialLink,
display: link.socialLink,
};
}
});
}
return socialLinks;
};
export {
getSocialLinks,
SOCIAL,
};

View File

@@ -1,18 +1,34 @@
export const configuration = {
BASE_URL: process.env.BASE_URL,
LMS_BASE_URL: process.env.LMS_BASE_URL,
CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL,
LOGIN_URL: process.env.LOGIN_URL,
ACCOUNT_SETTINGS_URL: `${process.env.LMS_BASE_URL}/account/settings`,
LOGOUT_URL: process.env.LOGOUT_URL,
CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH,
REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT,
DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
SECURE_COOKIES: process.env.NODE_ENV !== 'development',
SEGMENT_KEY: process.env.SEGMENT_KEY,
ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
USER_INFO_COOKIE_NAME: process.env.USER_INFO_COOKIE_NAME,
CSRF_COOKIE_NAME: process.env.CSRF_COOKIE_NAME,
LANGUAGE_PREFERENCE_COOKIE_NAME: process.env.LANGUAGE_PREFERENCE_COOKIE_NAME,
SITE_NAME: process.env.SITE_NAME,
MARKETING_SITE_BASE_URL: process.env.MARKETING_SITE_BASE_URL,
SUPPORT_URL: process.env.SUPPORT_URL,
CONTACT_URL: process.env.CONTACT_URL,
OPEN_SOURCE_URL: process.env.OPEN_SOURCE_URL,
TERMS_OF_SERVICE_URL: process.env.TERMS_OF_SERVICE_URL,
PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL,
FACEBOOK_URL: process.env.FACEBOOK_URL,
TWITTER_URL: process.env.TWITTER_URL,
YOU_TUBE_URL: process.env.YOU_TUBE_URL,
LINKED_IN_URL: process.env.LINKED_IN_URL,
GOOGLE_PLUS_URL: process.env.GOOGLE_PLUS_URL,
REDDIT_URL: process.env.REDDIT_URL,
APPLE_APP_STORE_URL: process.env.APPLE_APP_STORE_URL,
GOOGLE_PLAY_URL: process.env.GOOGLE_PLAY_URL,
ACCOUNT_SETTINGS_URL: `${process.env.LMS_BASE_URL}/account/settings`,
DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
SECURE_COOKIES: process.env.NODE_ENV !== 'development',
ENVIRONMENT: process.env.NODE_ENV,
ACCOUNTS_API_BASE_URL: `${process.env.LMS_BASE_URL}/api/user/v1/accounts`,
PREFERENCES_API_BASE_URL: `${process.env.LMS_BASE_URL}/api/user/v1/preferences`,

View File

@@ -1,26 +1,65 @@
import 'babel-polyfill';
import React from 'react';
import ReactDOM from 'react-dom';
import { identifyAuthenticatedUser, sendPageEvent } from '@edx/frontend-analytics';
import { identifyAuthenticatedUser, sendPageEvent, configureAnalytics, initializeSegment } from '@edx/frontend-analytics';
import LoggingService from '@edx/frontend-logging';
import { getAuthenticatedAPIClient } from '@edx/frontend-auth';
import './config/analytics';
import configureStore from './config/configureStore';
import apiClient from './config/apiClient';
import { configuration } from './environment';
import { handleRtl } from './i18n/i18n-loader';
import configureStore from './store';
import { configureProfileApiService } from './profile';
import { configureUserAccountApiService } from './common';
import './index.scss';
import App from './components/App';
const apiClient = getAuthenticatedAPIClient({
appBaseUrl: configuration.BASE_URL,
authBaseUrl: configuration.LMS_BASE_URL,
loginUrl: configuration.LOGIN_URL,
logoutUrl: configuration.LOGOUT_URL,
csrfTokenApiPath: configuration.CSRF_TOKEN_API_PATH,
refreshAccessTokenEndpoint: configuration.REFRESH_ACCESS_TOKEN_ENDPOINT,
accessTokenCookieName: configuration.ACCESS_TOKEN_COOKIE_NAME,
userInfoCookieName: configuration.USER_INFO_COOKIE_NAME,
csrfCookieName: configuration.CSRF_COOKIE_NAME,
});
/**
* We need to merge the application configuration with the authentication state
* so that we can hand it all to the redux store's initializer.
*/
function createInitialState() {
return Object.assign({}, { configuration }, apiClient.getAuthenticationState());
}
function configure() {
const { store, history } = configureStore(createInitialState(), configuration.ENVIRONMENT);
configureProfileApiService(configuration, apiClient);
configureUserAccountApiService(configuration, apiClient);
initializeSegment(configuration.SEGMENT_KEY);
configureAnalytics({
loggingService: LoggingService,
authApiClient: apiClient,
analyticsApiBaseUrl: configuration.LMS_BASE_URL,
});
if (configuration.ENVIRONMENT === 'production') {
handleRtl();
}
return {
store,
history,
};
}
apiClient.ensurePublicOrAuthenticationAndCookies(
window.location.pathname,
() => {
const { store, history } = configureStore();
if (process.env.NODE_ENV === 'production') {
handleRtl();
}
const { store, history } = configure();
ReactDOM.render(<App store={store} history={history} />, document.getElementById('root'));
@@ -28,3 +67,4 @@ apiClient.ensurePublicOrAuthenticationAndCookies(
sendPageEvent();
},
);

View File

@@ -15,7 +15,7 @@ $fa-font-path: "~font-awesome/fonts";
.btn, a.btn {
text-decoration: none;
&:hover {
text-decoration: none;
text-decoration: none;
}
}
.btn-link {
@@ -27,7 +27,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%;
}
@@ -71,7 +71,7 @@ $fa-font-path: "~font-awesome/fonts";
margin-bottom: 1.2rem;
}
}
.dropdown {
@include media-breakpoint-up(md) {
margin-bottom: 1.2rem;

View File

@@ -1,4 +1,6 @@
import AsyncActionType from './AsyncActionType';
import { utils } from '../common';
const { AsyncActionType } = utils;
export const FETCH_PROFILE = new AsyncActionType('PROFILE', 'FETCH_PROFILE');
export const SAVE_PROFILE = new AsyncActionType('PROFILE', 'SAVE_PROFILE');

View File

@@ -20,7 +20,7 @@ import {
deleteProfilePhotoSuccess,
deleteProfilePhotoReset,
deleteProfilePhoto,
} from './ProfileActions';
} from './actions';
describe('editable field actions', () => {
it('should create an open action', () => {

View File

Before

Width:  |  Height:  |  Size: 1006 B

After

Width:  |  Height:  |  Size: 1006 B

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,12 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { StatusAlert } from '@edx/paragon';
import { FormattedMessage } from 'react-intl';
import { configuration } from '../../config/environment';
const { ACCOUNT_SETTINGS_URL } = configuration;
function AgeMessage() {
function AgeMessage({ accountSettingsUrl }) {
return (
<StatusAlert
alertType="info"
@@ -24,7 +21,7 @@ function AgeMessage() {
description="error message"
tagName="p"
/>
<a href={ACCOUNT_SETTINGS_URL}>
<a href={accountSettingsUrl}>
<FormattedMessage
id="profile.age.set.date"
defaultMessage="Set your date of birth"
@@ -39,4 +36,8 @@ function AgeMessage() {
);
}
AgeMessage.propTypes = {
accountSettingsUrl: PropTypes.string.isRequired,
};
export default AgeMessage;

View File

@@ -3,8 +3,6 @@ import PropTypes from 'prop-types';
import { StatusAlert, Hyperlink } from '@edx/paragon';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-i18n'; // eslint-disable-line
// Analytics
import { sendTrackingLogEvent } from '@edx/frontend-analytics';
// Actions
@@ -16,25 +14,22 @@ import {
openForm,
closeForm,
updateDraft,
} from '../actions/ProfileActions';
} from '../actions';
// Components
import ProfileAvatar from './ProfilePage/ProfileAvatar';
import Name from './ProfilePage/Name';
import Country from './ProfilePage/Country';
import PreferredLanguage from './ProfilePage/PreferredLanguage';
import Education from './ProfilePage/Education';
import SocialLinks from './ProfilePage/SocialLinks';
import Bio from './ProfilePage/Bio';
import Certificates from './ProfilePage/Certificates';
import AgeMessage from './ProfilePage/AgeMessage';
import DateJoined from './ProfilePage/DateJoined';
import PageLoading from './common/PageLoading';
import Banner from './common/Banner';
import { profilePageSelector } from '../selectors/ProfilePageSelector';
// Configuration
import { configuration } from '../config/environment';
import ProfileAvatar from './forms/ProfileAvatar';
import Name from './forms/Name';
import Country from './forms/Country';
import PreferredLanguage from './forms/PreferredLanguage';
import Education from './forms/Education';
import SocialLinks from './forms/SocialLinks';
import Bio from './forms/Bio';
import Certificates from './forms/Certificates';
import AgeMessage from './AgeMessage';
import DateJoined from './DateJoined';
import { PageLoading } from '../../common';
import Banner from './Banner';
import { profilePageSelector } from '../selectors';
// i18n
import messages from './ProfilePage.messages';
@@ -89,7 +84,7 @@ export class ProfilePage extends React.Component {
}
return (
<Hyperlink className="btn btn-primary" href={configuration.VIEW_MY_RECORDS_URL} target="_blank">
<Hyperlink className="btn btn-primary" href={this.props.configuration.VIEW_MY_RECORDS_URL} target="_blank">
{this.props.intl.formatMessage(messages['profile.viewMyRecords'])}
</Hyperlink>
);
@@ -124,7 +119,17 @@ export class ProfilePage extends React.Component {
);
}
render() {
renderAgeMessage() {
const { requiresParentalConsent, isAuthenticatedUserProfile } = this.props;
const shouldShowAgeMessage = requiresParentalConsent && isAuthenticatedUserProfile;
if (!shouldShowAgeMessage) {
return null;
}
return <AgeMessage accountSettingsURL={this.props.configuration.ACCOUNT_SETTINGS_URL} />;
}
renderContent() {
const {
profileImage,
name,
@@ -142,11 +147,12 @@ export class ProfilePage extends React.Component {
bio,
visibilityBio,
requiresParentalConsent,
isAuthenticatedUserProfile,
isLoadingProfile,
} = this.props;
if (isLoadingProfile) return <PageLoading />;
if (isLoadingProfile) {
return <PageLoading />;
}
const commonFormProps = {
openHandler: this.handleOpen,
@@ -155,98 +161,108 @@ export class ProfilePage extends React.Component {
changeHandler: this.handleChange,
};
const shouldShowAgeMessage = requiresParentalConsent && isAuthenticatedUserProfile;
return (
<div className="profile-page">
<Banner />
<div className="container-fluid">
<div className="row align-items-center pt-4 mb-4 pt-md-0 mb-md-0">
<div className="col-auto col-md-4 col-lg-3">
<div className="d-flex align-items-center d-md-block">
<ProfileAvatar
className="mb-md-3"
src={profileImage.src}
isDefault={profileImage.isDefault}
onSave={this.handleSaveProfilePhoto}
onDelete={this.handleDeleteProfilePhoto}
savePhotoState={this.props.savePhotoState}
isEditable={this.props.isAuthenticatedUserProfile && !requiresParentalConsent}
/>
</div>
</div>
<div className="col pl-0">
<div className="d-md-none">
{this.renderHeadingLockup()}
</div>
<div className="d-none d-md-block float-right">
{this.renderViewMyRecordsButton()}
</div>
<div className="container-fluid">
<div className="row align-items-center pt-4 mb-4 pt-md-0 mb-md-0">
<div className="col-auto col-md-4 col-lg-3">
<div className="d-flex align-items-center d-md-block">
<ProfileAvatar
className="mb-md-3"
src={profileImage.src}
isDefault={profileImage.isDefault}
onSave={this.handleSaveProfilePhoto}
onDelete={this.handleDeleteProfilePhoto}
savePhotoState={this.props.savePhotoState}
isEditable={this.props.isAuthenticatedUserProfile && !requiresParentalConsent}
/>
</div>
</div>
{this.renderPhotoUploadErrorMessage()}
<div className="row">
<div className="col-md-4 col-lg-4">
<div className="d-none d-md-block mb-4">
{this.renderHeadingLockup()}
</div>
<div className="d-md-none mb-4">
{this.renderViewMyRecordsButton()}
</div>
<Name
name={name}
visibilityName={visibilityName}
formId="name"
{...commonFormProps}
/>
<Country
country={country}
visibilityCountry={visibilityCountry}
formId="country"
{...commonFormProps}
/>
<PreferredLanguage
languageProficiencies={languageProficiencies}
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
formId="languageProficiencies"
{...commonFormProps}
/>
<Education
levelOfEducation={levelOfEducation}
visibilityLevelOfEducation={visibilityLevelOfEducation}
formId="levelOfEducation"
{...commonFormProps}
/>
<SocialLinks
socialLinks={socialLinks}
draftSocialLinksByPlatform={draftSocialLinksByPlatform}
visibilitySocialLinks={visibilitySocialLinks}
formId="socialLinks"
{...commonFormProps}
/>
<div className="col pl-0">
<div className="d-md-none">
{this.renderHeadingLockup()}
</div>
<div className="pt-md-3 col-md-8 col-lg-7 offset-lg-1">
{shouldShowAgeMessage ? <AgeMessage accountURL="#account" /> : null}
<Bio
bio={bio}
visibilityBio={visibilityBio}
formId="bio"
{...commonFormProps}
/>
<Certificates
visibilityCourseCertificates={visibilityCourseCertificates}
formId="certificates"
{...commonFormProps}
/>
<div className="d-none d-md-block float-right">
{this.renderViewMyRecordsButton()}
</div>
</div>
</div>
{this.renderPhotoUploadErrorMessage()}
<div className="row">
<div className="col-md-4 col-lg-4">
<div className="d-none d-md-block mb-4">
{this.renderHeadingLockup()}
</div>
<div className="d-md-none mb-4">
{this.renderViewMyRecordsButton()}
</div>
<Name
name={name}
visibilityName={visibilityName}
formId="name"
{...commonFormProps}
/>
<Country
country={country}
visibilityCountry={visibilityCountry}
formId="country"
{...commonFormProps}
/>
<PreferredLanguage
languageProficiencies={languageProficiencies}
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
formId="languageProficiencies"
{...commonFormProps}
/>
<Education
levelOfEducation={levelOfEducation}
visibilityLevelOfEducation={visibilityLevelOfEducation}
formId="levelOfEducation"
{...commonFormProps}
/>
<SocialLinks
socialLinks={socialLinks}
draftSocialLinksByPlatform={draftSocialLinksByPlatform}
visibilitySocialLinks={visibilitySocialLinks}
formId="socialLinks"
{...commonFormProps}
/>
</div>
<div className="pt-md-3 col-md-8 col-lg-7 offset-lg-1">
{this.renderAgeMessage()}
<Bio
bio={bio}
visibilityBio={visibilityBio}
formId="bio"
{...commonFormProps}
/>
<Certificates
visibilityCourseCertificates={visibilityCourseCertificates}
formId="certificates"
{...commonFormProps}
/>
</div>
</div>
</div>
);
}
render() {
return (
<div className="profile-page">
<Banner />
{this.renderContent()}
</div>
);
}
}
ProfilePage.propTypes = {
// Config
configuration: PropTypes.shape({
VIEW_MY_RECORDS_URL: PropTypes.string.isRequired,
ACCOUNT_SETTINGS_URL: PropTypes.string.isRequired,
}).isRequired,
// Account data
username: PropTypes.string,
requiresParentalConsent: PropTypes.bool,

View File

@@ -9,7 +9,6 @@ import configureMockStore from 'redux-mock-store';
import * as analytics from '@edx/frontend-analytics';
import ConnectedProfilePage from './ProfilePage';
const mockStore = configureMockStore();
const storeMocks = {
loadingApp: require('./__mocks__/loadingApp.mockStore.js'),

View File

@@ -3,6 +3,10 @@ module.exports = {
userId: 9,
username: 'staff'
},
configuration: {
VIEW_MY_RECORDS_URL: 'http://localhost:18150/records',
ACCOUNT_SETTINGS_URL: 'http://localhost:18000/account/settings',
},
userAccount: {
loading: false,
error: null,

View File

@@ -3,6 +3,10 @@ module.exports = {
userId: 9,
username: 'staff'
},
configuration: {
VIEW_MY_RECORDS_URL: 'http://localhost:18150/records',
ACCOUNT_SETTINGS_URL: 'http://localhost:18000/account/settings',
},
userAccount: {
loading: false,
error: null,

View File

@@ -3,6 +3,10 @@ module.exports = {
userId: 9,
username: 'staff'
},
configuration: {
VIEW_MY_RECORDS_URL: 'http://localhost:18150/records',
ACCOUNT_SETTINGS_URL: 'http://localhost:18000/account/settings',
},
userAccount: {
loading: false,
error: null,

View File

@@ -3,6 +3,10 @@ module.exports = {
userId: 9,
username: 'staff'
},
configuration: {
VIEW_MY_RECORDS_URL: 'http://localhost:18150/records',
ACCOUNT_SETTINGS_URL: 'http://localhost:18000/account/settings',
},
userAccount: {
loading: false,
error: null,

View File

@@ -1,22 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ProfilePage /> Renders correctly in various states app loading 1`] = `
<div>
<div
className="profile-page"
>
<div
className="profile-page-bg-banner bg-primary d-none d-md-block p-relative"
/>
<div
className="d-flex justify-content-center align-items-center flex-column"
style={
Object {
"height": "50vh",
}
}
>
<div>
<div
className="spinner-border text-primary"
role="status"
/>
className="d-flex justify-content-center align-items-center flex-column"
style={
Object {
"height": "50vh",
}
}
>
<div
className="spinner-border text-primary"
role="status"
/>
</div>
</div>
</div>
`;
@@ -332,7 +336,7 @@ exports[`<ProfilePage /> Renders correctly in various states viewing own profile
>
<a
className="btn btn-primary"
href="undefined/records"
href="http://localhost:18150/records"
onClick={[Function]}
rel="noopener"
target="_blank"
@@ -384,7 +388,7 @@ exports[`<ProfilePage /> Renders correctly in various states viewing own profile
>
<a
className="btn btn-primary"
href="undefined/records"
href="http://localhost:18150/records"
onClick={[Function]}
rel="noopener"
target="_blank"
@@ -1344,7 +1348,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
>
<a
className="btn btn-primary"
href="undefined/records"
href="http://localhost:18150/records"
onClick={[Function]}
rel="noopener"
target="_blank"
@@ -1396,7 +1400,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
>
<a
className="btn btn-primary"
href="undefined/records"
href="http://localhost:18150/records"
onClick={[Function]}
rel="noopener"
target="_blank"

View File

@@ -14,7 +14,7 @@ import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../../selectors/ProfilePageSelector';
import { editableFormSelector } from '../../selectors';
class Bio extends React.Component {
constructor(props) {

View File

@@ -14,12 +14,12 @@ import EditableItemHeader from './elements/EditableItemHeader';
import SwitchContent from './elements/SwitchContent';
// Assets
import microMastersSVG from '../../../assets/micro-masters.svg';
import professionalCertificateSVG from '../../../assets/professional-certificate.svg';
import verifiedCertificateSVG from '../../../assets/verified-certificate.svg';
import microMastersSVG from '../../assets/micro-masters.svg';
import professionalCertificateSVG from '../../assets/professional-certificate.svg';
import verifiedCertificateSVG from '../../assets/verified-certificate.svg';
// Selectors
import { certificatesSelector } from '../../selectors/ProfilePageSelector';
import { certificatesSelector } from '../../selectors';
class Certificates extends React.Component {
constructor(props) {

View File

@@ -13,7 +13,7 @@ import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { countrySelector } from '../../selectors/ProfilePageSelector';
import { countrySelector } from '../../selectors';
class Country extends React.Component {
constructor(props) {

View File

@@ -15,10 +15,10 @@ import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Constants
import EDUCATION_LEVELS from '../../constants/education';
import { EDUCATION_LEVELS } from '../../constants';
// Selectors
import { editableFormSelector } from '../../selectors/ProfilePageSelector';
import { editableFormSelector } from '../../selectors';
class Education extends React.Component {
constructor(props) {

View File

@@ -12,7 +12,7 @@ import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../../selectors/ProfilePageSelector';
import { editableFormSelector } from '../../selectors';
class Name extends React.Component {
constructor(props) {

View File

@@ -13,7 +13,7 @@ import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { preferredLanguageSelector } from '../../selectors/ProfilePageSelector';
import { preferredLanguageSelector } from '../../selectors';
class PreferredLanguage extends React.Component {
constructor(props) {

View File

@@ -17,7 +17,7 @@ import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../../selectors/ProfilePageSelector';
import { editableFormSelector } from '../../selectors';
const platformDisplayInfo = {
facebook: {

View File

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import EditButton from './EditButton';
import { Visibility } from './Visibility';
function EditableItemHeader({
content,
showVisibility,

28
src/profile/constants.js Normal file
View File

@@ -0,0 +1,28 @@
const EDUCATION_LEVELS = [
'p',
'm',
'b',
'a',
'hs',
'jhs',
'el',
'none',
'o',
];
const SOCIAL = {
linkedin: {
title: 'LinkedIn',
},
twitter: {
title: 'Twitter',
},
facebook: {
title: 'Facebook',
},
};
export {
EDUCATION_LEVELS,
SOCIAL,
};

11
src/profile/index.js Normal file
View File

@@ -0,0 +1,11 @@
import profileReducer from './reducers';
import profileSaga from './sagas';
import ConnectedProfilePage from './components/ProfilePage';
import configureProfileApiService from './services';
export {
ConnectedProfilePage,
profileReducer,
profileSaga,
configureProfileApiService,
};

View File

@@ -7,7 +7,7 @@ import {
FETCH_PROFILE,
UPDATE_DRAFT,
RESET_DRAFTS,
} from '../actions/ProfileActions';
} from './actions';
export const initialState = {
errors: {},

View File

@@ -25,16 +25,18 @@ import {
deleteProfilePhotoSuccess,
deleteProfilePhotoReset,
resetDrafts,
} from '../actions/ProfileActions';
} from './actions';
// Selectors
import { handleSaveProfileSelector, handleFetchProfileSelector } from '../selectors/ProfilePageSelector';
import { handleSaveProfileSelector, handleFetchProfileSelector } from './selectors';
// Services
import * as ProfileApiService from '../services/ProfileApiService';
import * as ProfileApiService from './services';
// Utils
import { keepKeys } from '../services/utils';
import { utils } from '../common';
const { keepKeys } = utils;
export function* handleFetchProfile(action) {
const { username } = action.payload;
@@ -188,7 +190,7 @@ export function* handleFetchUserAccountFailure(action) {
yield put(push('/error'));
}
export default function* rootSaga() {
export default function* profileSaga() {
yield takeEvery(FETCH_PROFILE.BASE, handleFetchProfile);
yield takeEvery(SAVE_PROFILE.BASE, handleSaveProfile);
yield takeEvery(SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto);

View File

@@ -1,10 +1,10 @@
import { takeEvery, put, call, delay, select, all } from 'redux-saga/effects';
import { FETCH_USER_ACCOUNT_FAILURE } from '@edx/frontend-auth';
import * as profileActions from '../actions/ProfileActions';
import { handleSaveProfileSelector, handleFetchProfileSelector } from '../selectors/ProfilePageSelector';
import * as profileActions from './actions';
import { handleSaveProfileSelector, handleFetchProfileSelector } from './selectors';
jest.mock('../services/ProfileApiService', () => ({
jest.mock('./services', () => ({
getProfile: jest.fn(),
patchProfile: jest.fn(),
postProfilePhoto: jest.fn(),
@@ -16,20 +16,20 @@ jest.mock('../services/ProfileApiService', () => ({
// RootSaga and ProfileApiService must be imported AFTER the mock above.
/* eslint-disable import/first */
import rootSaga, {
import profileSaga, {
handleFetchProfile,
handleSaveProfile,
handleSaveProfilePhoto,
handleDeleteProfilePhoto,
handleFetchUserAccountFailure,
} from './RootSaga';
import * as ProfileApiService from '../services/ProfileApiService';
} from './sagas';
import * as ProfileApiService from './services';
/* eslint-enable import/first */
describe('RootSaga', () => {
describe('rootSaga', () => {
describe('profileSaga', () => {
it('should pass actions to the correct sagas', () => {
const gen = rootSaga();
const gen = profileSaga();
expect(gen.next().value)
.toEqual(takeEvery(profileActions.FETCH_PROFILE.BASE, handleFetchProfile));

View File

@@ -16,6 +16,7 @@ export const savePhotoStateSelector = state => state.profilePage.savePhotoState;
export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile;
export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField;
export const accountErrorsSelector = state => state.profilePage.errors;
export const configurationSelector = state => state.configuration;
export const isAuthenticatedUserProfileSelector = createSelector(
authenticationUsernameSelector,
@@ -329,6 +330,7 @@ export const profilePageSelector = createSelector(
isAuthenticatedUserProfileSelector,
draftSocialLinksByPlatformSelector,
accountErrorsSelector,
configurationSelector,
(
account,
formValues,
@@ -339,6 +341,7 @@ export const profilePageSelector = createSelector(
isAuthenticatedUserProfile,
draftSocialLinksByPlatform,
errors,
configuration,
) => ({
// Account data we need
username: account.username,
@@ -381,5 +384,6 @@ export const profilePageSelector = createSelector(
isLoadingProfile,
isAuthenticatedUserProfile,
photoUploadError: errors.photo || null,
configuration,
}),
);

View File

@@ -1,12 +1,32 @@
import LoggingService from '@edx/frontend-logging';
import pick from 'lodash.pick';
import apiClient from '../config/apiClient';
import { configuration } from '../config/environment';
import {
camelCaseObject,
convertKeyNames,
snakeCaseObject,
} from './utils';
import { utils } from '../common';
const { camelCaseObject, convertKeyNames, snakeCaseObject } = utils;
let config = {
ACCOUNTS_API_BASE_URL: null,
CERTIFICATES_API_BASE_URL: null,
LMS_BASE_URL: null,
PREFERENCES_API_BASE_URL: null,
};
let apiClient = null;
function validateConfiguration(newConfig) {
Object.keys(config).forEach((key) => {
if (newConfig[key] === undefined) {
throw new Error(`Service configuration error: ${key} is required.`);
}
});
}
export default function configure(newConfig, newApiClient) {
validateConfiguration(newConfig);
config = pick(newConfig, Object.keys(config));
apiClient = newApiClient;
}
function processAccountData(data) {
return camelCaseObject(data);
@@ -24,7 +44,7 @@ function processAndThrowError(error, errorDataProcessor) {
// GET ACCOUNT
export async function getAccount(username) {
const { data } = await apiClient.get(`${configuration.ACCOUNTS_API_BASE_URL}/${username}`);
const { data } = await apiClient.get(`${config.ACCOUNTS_API_BASE_URL}/${username}`);
// Process response data
return processAccountData(data);
@@ -34,17 +54,15 @@ export async function getAccount(username) {
export async function patchProfile(username, params) {
const processedParams = snakeCaseObject(params);
const { data } = await apiClient.patch(
`${configuration.ACCOUNTS_API_BASE_URL}/${username}`,
processedParams,
{
const { data } = await apiClient
.patch(`${config.ACCOUNTS_API_BASE_URL}/${username}`, processedParams, {
headers: {
'Content-Type': 'application/merge-patch+json',
},
},
).catch((error) => {
processAndThrowError(error, processAccountData);
});
})
.catch((error) => {
processAndThrowError(error, processAccountData);
});
// Process response data
return processAccountData(data);
@@ -55,7 +73,7 @@ export async function patchProfile(username, params) {
export async function postProfilePhoto(username, formData) {
// eslint-disable-next-line no-unused-vars
const { data } = await apiClient.post(
`${configuration.ACCOUNTS_API_BASE_URL}/${username}/image`,
`${config.ACCOUNTS_API_BASE_URL}/${username}/image`,
formData,
{
headers: {
@@ -79,7 +97,7 @@ export async function postProfilePhoto(username, formData) {
export async function deleteProfilePhoto(username) {
// eslint-disable-next-line no-unused-vars
const { data } = await apiClient.delete(`${configuration.ACCOUNTS_API_BASE_URL}/${username}/image`);
const { data } = await apiClient.delete(`${config.ACCOUNTS_API_BASE_URL}/${username}/image`);
// TODO: Someday in the future the POST photo endpoint
// will return the new values. At that time we should
@@ -92,7 +110,7 @@ export async function deleteProfilePhoto(username) {
// GET PREFERENCES
export async function getPreferences(username) {
const { data } = await apiClient.get(`${configuration.PREFERENCES_API_BASE_URL}/${username}`);
const { data } = await apiClient.get(`${config.PREFERENCES_API_BASE_URL}/${username}`);
return camelCaseObject(data);
}
@@ -112,11 +130,9 @@ export async function patchPreferences(username, params) {
visibility_time_zone: 'visibility.time_zone',
});
await apiClient.patch(
`${configuration.PREFERENCES_API_BASE_URL}/${username}`,
processedParams,
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
);
await apiClient.patch(`${config.PREFERENCES_API_BASE_URL}/${username}`, processedParams, {
headers: { 'Content-Type': 'application/merge-patch+json' },
});
return params; // TODO: Once the server returns the updated preferences object, return that.
}
@@ -129,15 +145,14 @@ function transformCertificateData(data) {
transformedData.push({
...camelCaseObject(cert),
certificateType: cert.certificate_type,
downloadUrl: new URL(cert.download_url, configuration.LMS_BASE_URL).toString(),
downloadUrl: new URL(cert.download_url, config.LMS_BASE_URL).toString(),
});
});
return transformedData;
}
export async function getCourseCertificates(username) {
const url = `${configuration.CERTIFICATES_API_BASE_URL}/${username}/`;
const url = `${config.CERTIFICATES_API_BASE_URL}/${username}/`;
try {
const { data } = await apiClient.get(url);
return transformCertificateData(data);

View File

@@ -2,7 +2,7 @@ import { combineReducers } from 'redux';
import { userAccount } from '@edx/frontend-auth';
import { connectRouter } from 'connected-react-router';
import profilePage from './ProfilePageReducer';
import { profileReducer } from './profile';
const identityReducer = (state) => {
const newState = { ...state };
@@ -14,8 +14,9 @@ const createRootReducer = history =>
// The authentication state is added as initialState when
// creating the store in data/store.js.
authentication: identityReducer,
configuration: identityReducer,
userAccount,
profilePage,
profilePage: profileReducer,
router: connectRouter(history),
});

9
src/sagas.js Normal file
View File

@@ -0,0 +1,9 @@
import { all } from 'redux-saga/effects';
import { profileSaga } from './profile';
export default function* rootSaga() {
yield all([
profileSaga(),
]);
}

View File

@@ -6,18 +6,16 @@ import { routerMiddleware } from 'connected-react-router';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import apiClient from './apiClient';
import createRootReducer from '../reducers/RootReducer';
import rootSaga from '../sagas/RootSaga';
import createRootReducer from '../reducers';
import rootSaga from '../sagas';
export default function configureStore() {
export default function configureStore(initialState = {}) {
const history = createBrowserHistory();
const loggerMiddleware = createLogger({
collapsed: true,
});
const sagaMiddleware = createSagaMiddleware();
const initialState = apiClient.getAuthenticationState();
const store = createStore(
createRootReducer(history),

View File

@@ -4,15 +4,13 @@ import thunkMiddleware from 'redux-thunk';
import { createBrowserHistory } from 'history';
import { routerMiddleware } from 'connected-react-router';
import apiClient from './apiClient';
import createRootReducer from '../reducers/RootReducer';
import rootSaga from '../sagas/RootSaga';
import createRootReducer from '../reducers';
import rootSaga from '../sagas';
export default function configureStore() {
export default function configureStore(initialState = {}) {
const history = createBrowserHistory();
const sagaMiddleware = createSagaMiddleware();
const initialState = apiClient.getAuthenticationState();
const store = createStore(
createRootReducer(history),

9
src/store/index.js Normal file
View File

@@ -0,0 +1,9 @@
import configureStoreProd from './configureStore.prod';
import configureStoreDev from './configureStore.dev';
export default function configureStore(state, env) {
if (env === 'production') {
return configureStoreProd(state);
}
return configureStoreDev(state);
}

View File

@@ -145,8 +145,6 @@ module.exports = Merge.smart(commonConfig, {
USER_INFO_COOKIE_NAME: null,
CSRF_COOKIE_NAME: 'csrftoken',
LANGUAGE_PREFERENCE_COOKIE_NAME: null,
NEW_RELIC_APP_ID: null,
NEW_RELIC_LICENSE_KEY: null,
SITE_NAME: null,
MARKETING_SITE_BASE_URL: null,
SUPPORT_URL: null,
@@ -162,6 +160,8 @@ module.exports = Merge.smart(commonConfig, {
REDDIT_URL: null,
APPLE_APP_STORE_URL: null,
GOOGLE_PLAY_URL: null,
NEW_RELIC_APP_ID: null,
NEW_RELIC_LICENSE_KEY: null,
}),
new HtmlWebpackNewRelicPlugin({
// This plugin fixes an issue where the newrelic script will break if