├── .eslintignore
├── public
└── README.md
├── src
├── pages
│ ├── Home
│ │ ├── Home.scss
│ │ └── Home.js
│ ├── ReduxDemo
│ │ ├── ReduxDemo.scss
│ │ └── ReduxDemo.js
│ ├── Login
│ │ ├── Login.scss
│ │ └── Login.js
│ ├── __tests__
│ │ ├── Home.js
│ │ ├── UsersList.js
│ │ ├── Login.js
│ │ ├── Signup.js
│ │ └── helpers
│ │ │ └── index.js
│ ├── Signup
│ │ ├── Signup.scss
│ │ └── Signup.js
│ ├── NotFound
│ │ └── NotFound.js
│ ├── UsersList
│ │ └── UsersList.js
│ └── UserDetail
│ │ └── UserDetail.js
├── components
│ ├── App
│ │ ├── App.scss
│ │ └── App.js
│ ├── PageLayout
│ │ ├── PageLayout.scss
│ │ └── PageLayout.js
│ ├── Navbar
│ │ ├── Navbar.scss
│ │ └── Navbar.js
│ ├── Loading
│ │ └── Loading.js
│ ├── TextInput
│ │ └── TextInput.js
│ ├── __tests__
│ │ ├── PageLayout.js
│ │ └── Navbar.js
│ └── Page
│ │ └── Page.js
├── redux
│ ├── rootSaga.js
│ ├── rootReducer.js
│ ├── demo
│ │ ├── actions.js
│ │ ├── reducer.js
│ │ └── saga.js
│ ├── sagaHelpers.js
│ ├── user
│ │ ├── actions.js
│ │ ├── saga.js
│ │ └── reducer.js
│ ├── configureStore.js
│ └── routesMap.js
├── server
│ ├── apiProxy.js
│ ├── render.js
│ └── server.js
├── helpers
│ ├── createTheme.js
│ ├── request.js
│ └── validators.js
└── client.js
├── .gitignore
├── scripts
├── deploy.sh
└── redeploy.sh
├── jest.config.js
├── .babelrc
├── config
└── setupJest.js
├── webpack
├── webpack.server.config.js
├── webpack.parts.js
└── webpack.client.config.js
├── package.json
├── .eslintrc.js
└── README.md
/.eslintignore:
--------------------------------------------------------------------------------
1 | coverage
2 | build
--------------------------------------------------------------------------------
/public/README.md:
--------------------------------------------------------------------------------
1 | ### Static assets served here
--------------------------------------------------------------------------------
/src/pages/Home/Home.scss:
--------------------------------------------------------------------------------
1 | .homePage {
2 | opacity: 1;
3 | }
4 |
5 | .wrap {
6 | text-align: center;
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage/
3 | build/client
4 | build/server
5 | webpack/records.json
6 | .DS_Store
7 | .env
--------------------------------------------------------------------------------
/scripts/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd ~/webapps/universal-web-boilerplate
3 | git pull origin master
4 | yarn
5 | npm run build
6 | npm run start
7 |
--------------------------------------------------------------------------------
/scripts/redeploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd ~/webapps/universal-web-boilerplate
3 | git pull origin master
4 | yarn
5 | npm run build
6 | forever restart universal-web
7 |
--------------------------------------------------------------------------------
/src/pages/ReduxDemo/ReduxDemo.scss:
--------------------------------------------------------------------------------
1 | .reduxDemo {
2 | opacity: 1;
3 | }
4 |
5 | .wrap {
6 | text-align: center;
7 | }
8 |
9 | .column {
10 | padding: 20px 10px;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/App/App.scss:
--------------------------------------------------------------------------------
1 | :global(a) {
2 | text-decoration: none;
3 | // color: white;
4 | }
5 | :global(html) {
6 | height: 100%;
7 | }
8 |
9 | .app {
10 | opacity: 1;
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/Login/Login.scss:
--------------------------------------------------------------------------------
1 | .loginPage {
2 | opacity: 1;
3 | }
4 |
5 | .formWrap {
6 | max-width: 400px;
7 | margin: auto;
8 | margin-top: 40px;
9 | margin-bottom: 40px;
10 | padding: 10px;
11 | background: #FAFAFA;
12 | }
13 |
14 | .textField {
15 | width: 100%;
16 | }
--------------------------------------------------------------------------------
/src/components/PageLayout/PageLayout.scss:
--------------------------------------------------------------------------------
1 | .pageContent {
2 | padding: 40px 10px 0 10px;
3 | margin: auto;
4 | margin-top: 56px;
5 | max-width: 960px;
6 | width: 100%;
7 | @media (min-width: 0px) {
8 | margin-top: 48px;
9 | }
10 | @media (min-width: 600px) {
11 | margin-top: 64px;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/__tests__/Home.js:
--------------------------------------------------------------------------------
1 | import HomePage from '../Home/Home';
2 | import { mountPage } from './helpers';
3 |
4 |
5 | describe('Home page', () => {
6 |
7 | test('Should mount and render', () => {
8 | const homePage = mountPage(HomePage, {});
9 | expect(homePage).toEqual(expect.anything());
10 | });
11 |
12 | });
13 |
--------------------------------------------------------------------------------
/src/components/Navbar/Navbar.scss:
--------------------------------------------------------------------------------
1 | .appBar {
2 | // max-width: 1000px;
3 | }
4 | .toolBar {
5 | margin: auto;
6 | width: 100%;
7 | max-width: 960px;
8 | min-height: 60px;
9 | }
10 |
11 | .navbarWrapper {
12 | padding: 0 10px;
13 | }
14 |
15 | .middleContent {
16 | flex: 1;
17 | a {
18 | color: white;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/redux/rootSaga.js:
--------------------------------------------------------------------------------
1 | import {
2 | fork,
3 | all,
4 | } from 'redux-saga/effects';
5 | import demoSaga from 'redux/demo/saga';
6 | import userSaga from 'redux/user/saga';
7 |
8 |
9 | export default function* rootSaga(context) {
10 | yield all([
11 | fork(demoSaga, context),
12 | fork(userSaga, context),
13 | ]);
14 | }
15 |
--------------------------------------------------------------------------------
/src/pages/Signup/Signup.scss:
--------------------------------------------------------------------------------
1 | .signupPage {
2 | opacity: 1;
3 | }
4 |
5 | .formWrap {
6 | max-width: 400px;
7 | margin: auto;
8 | margin-top: 40px;
9 | margin-bottom: 40px;
10 | padding: 10px;
11 | background: #FAFAFA;
12 | }
13 |
14 | .textField {
15 | width: 100%;
16 | }
17 |
18 | .submitBtn {
19 | width: 100%;
20 | }
21 |
--------------------------------------------------------------------------------
/src/pages/NotFound/NotFound.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Typography from '@material-ui/core/Typography';
3 |
4 |
5 | const NotFoundPage = () => {
6 | return (
7 |
8 | Page Not Found
9 |
10 | );
11 | };
12 |
13 | export default NotFoundPage;
14 |
--------------------------------------------------------------------------------
/src/components/Loading/Loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import CircularProgress from '@material-ui/core/CircularProgress';
3 |
4 |
5 | const Loading = () => (
6 |
13 | );
14 |
15 | export default Loading;
16 |
--------------------------------------------------------------------------------
/src/server/apiProxy.js:
--------------------------------------------------------------------------------
1 | import httpProxy from 'http-proxy';
2 |
3 |
4 | function createProxy( target ) {
5 | const proxy = httpProxy.createProxyServer();
6 |
7 | return (req, res, next) => {
8 | proxy.web(req, res, {
9 | target,
10 | changeOrigin: true,
11 | }, ( error ) => {
12 | next(error);
13 | });
14 | };
15 | }
16 |
17 | export default createProxy;
18 |
--------------------------------------------------------------------------------
/src/redux/rootReducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import demoReducer, { STORE_KEY as DEMO_STORE_KEY } from 'redux/demo/reducer';
3 | import userReducer, { STORE_KEY as USER_STORE_KEY } from 'redux/user/reducer';
4 |
5 | export default ( routeReducer ) => {
6 | return combineReducers({
7 | [DEMO_STORE_KEY]: demoReducer,
8 | [USER_STORE_KEY]: userReducer,
9 | location: routeReducer,
10 | });
11 | };
12 |
--------------------------------------------------------------------------------
/src/helpers/createTheme.js:
--------------------------------------------------------------------------------
1 | import { createMuiTheme } from '@material-ui/core/styles';
2 | import red from '@material-ui/core/colors/red';
3 |
4 |
5 | export default () => {
6 | return createMuiTheme({
7 | palette: {
8 | primary: {
9 | main: '#000',
10 | },
11 | secondary: {
12 | main: '#FFF',
13 | },
14 | error: {
15 | main: red[500],
16 | },
17 | },
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/src/redux/demo/actions.js:
--------------------------------------------------------------------------------
1 | export const INCREMENT_COUNTER = 'INCREMENT_COUNTER';
2 | export const DECREMENT_COUNTER = 'DECREMENT_COUNTER';
3 | export const INCREMENT_COUNTER_ASYNC = 'INCREMENT_COUNTER_ASYNC';
4 | export const DECREMENT_COUNTER_ASYNC = 'DECREMENT_COUNTER_ASYNC';
5 |
6 | export const LOAD_DATA_REQUESTED = 'LOAD_DATA_REQUESTED';
7 | export const LOAD_DATA_STARTED = 'LOAD_DATA_STARTED';
8 | export const LOAD_DATA_SUCCESS = 'LOAD_DATA_SUCCESS';
9 | export const LOAD_DATA_ERROR = 'LOAD_DATA_ERROR';
10 |
--------------------------------------------------------------------------------
/src/components/PageLayout/PageLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styles from 'components/PageLayout/PageLayout.scss';
4 | import Navbar from 'components/Navbar/Navbar';
5 |
6 |
7 | const PageLayout = ({ children }) => {
8 | return (
9 |
10 |
11 |
12 | {children}
13 |
14 |
15 | );
16 | };
17 | PageLayout.propTypes = {
18 | children: PropTypes.node.isRequired,
19 | };
20 |
21 | export default PageLayout;
22 |
--------------------------------------------------------------------------------
/src/redux/sagaHelpers.js:
--------------------------------------------------------------------------------
1 | import {
2 | take,
3 | } from 'redux-saga/effects';
4 |
5 |
6 | export function delay( ms ) {
7 | return () => (
8 | new Promise((resolve) => {
9 | setTimeout(resolve, ms);
10 | })
11 | );
12 | }
13 |
14 | export function takeOne( actionType, fn, ...args ) {
15 | return function* () { // eslint-disable-line func-names
16 | while (true) { // eslint-disable-line no-constant-condition
17 | const action = yield take(actionType);
18 | yield* fn(action, ...args);
19 | }
20 | };
21 | }
22 |
23 | export const obj = {};
24 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testEnvironment: 'jsdom',
3 | rootDir: './src',
4 | testPathIgnorePatterns: [
5 | '/../node_modules/',
6 | 'helpers',
7 | ],
8 | moduleNameMapper: {
9 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
10 | '/../../jest-config/__mocks__/fileMock.js',
11 | '\\.(css|scss|less)$': 'identity-obj-proxy', // NOTE: This would be required for local scope css
12 | },
13 | setupTestFrameworkScriptFile: '/../config/setupJest.js',
14 | snapshotSerializers: [
15 | 'enzyme-to-json/serializer',
16 | ],
17 | };
18 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "babel-preset-env",
4 | "babel-preset-react",
5 | "babel-preset-stage-1"
6 | ],
7 | "plugins": [
8 | [ "babel-plugin-module-resolver",
9 | {
10 | "root": [
11 | "./src"
12 | ]
13 | }
14 | ],
15 | "babel-plugin-universal-import",
16 | ["babel-plugin-transform-runtime", {
17 | "polyfill": false,
18 | "regenerator": true
19 | }],
20 | "babel-plugin-transform-decorators-legacy",
21 | ],
22 | "env": {
23 | "development": {
24 | "plugins": [
25 | "react-hot-loader/babel"
26 | ]
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/TextInput/TextInput.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import TextField from '@material-ui/core/TextField';
4 |
5 |
6 | const TextInput = ({
7 | input,
8 | meta,
9 | ...rest
10 | }) => {
11 | const showError = Boolean(meta.touched && meta.error);
12 | return (
13 | input.onChange(event.target.value)}
17 | value={input.value}
18 | error={showError}
19 | helperText={showError ? meta.error : ( rest.helperText || '' )}
20 | />
21 | );
22 | };
23 | TextInput.propTypes = {
24 | input: PropTypes.object.isRequired,
25 | meta: PropTypes.object.isRequired,
26 | };
27 |
28 | export default TextInput;
29 |
--------------------------------------------------------------------------------
/src/components/App/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import Page from 'components/Page/Page';
4 | import PageLayout from 'components/PageLayout/PageLayout';
5 | import styles from 'components/App/App.scss';
6 |
7 |
8 | class App extends Component {
9 |
10 | componentDidMount() {
11 | // Remove JSS injected for material UI
12 | const jssStyles = document.getElementById('jss-server-side');
13 | if (jssStyles && jssStyles.parentNode) {
14 | jssStyles.parentNode.removeChild(jssStyles);
15 | }
16 | }
17 |
18 | render() {
19 | return (
20 |
25 | );
26 | }
27 | }
28 | export default App;
29 |
--------------------------------------------------------------------------------
/src/components/__tests__/PageLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { mount } from 'enzyme';
3 | import PageLayout from 'components/PageLayout/PageLayout';
4 | import { wrapWithProviders } from 'pages/__tests__/helpers';
5 |
6 |
7 | describe('PageLayout component', () => {
8 |
9 | let layoutInstance = null;
10 | beforeAll((done) => {
11 | layoutInstance = mount(wrapWithProviders(
12 | Test
13 | ));
14 | done();
15 | });
16 |
17 | test('Should mount and render', () => {
18 | expect(layoutInstance).toEqual(expect.anything());
19 | });
20 |
21 | test('Should contain a navbar and some inner content', () => {
22 | expect(layoutInstance.find('Navbar').exists()).toBe(true);
23 | expect(layoutInstance.contains('Test')).toBe(true);
24 | });
25 |
26 | });
27 |
--------------------------------------------------------------------------------
/config/setupJest.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import dotenv from 'dotenv';
3 | import 'raf/polyfill';
4 | import 'jest-enzyme';
5 | import Enzyme from 'enzyme';
6 | import Adapter from 'enzyme-adapter-react-16';
7 | Enzyme.configure({ adapter: new Adapter() });
8 |
9 | // setup envs
10 | const envs = dotenv.load({ path: path.resolve(__dirname, '../.env') });
11 | Object.assign(window, envs.parsed, {
12 | __SERVER__: 'false',
13 | __CLIENT__: 'true',
14 | __TEST__: 'true',
15 | });
16 |
17 | // mock local storage
18 | // https://github.com/tmpvar/jsdom/issues/1137
19 | const inMemoryLocalStorage = {};
20 | window.localStorage = {
21 | setItem(key, val) {
22 | inMemoryLocalStorage[key] = val;
23 | },
24 | getItem(key) {
25 | return inMemoryLocalStorage[key];
26 | },
27 | removeItem(key) {
28 | delete inMemoryLocalStorage[key];
29 | },
30 | };
31 |
32 | // mock fetch API
33 | global.fetch = require('jest-fetch-mock');
34 |
--------------------------------------------------------------------------------
/src/redux/user/actions.js:
--------------------------------------------------------------------------------
1 | export const LOAD_USER_REQUESTED = 'LOAD_USER_REQUESTED';
2 | export const LOAD_USER_STARTED = 'LOAD_USER_STARTED';
3 | export const LOAD_USER_SUCCESS = 'LOAD_USER_SUCCESS';
4 | export const LOAD_USER_ERROR = 'LOAD_USER_ERROR';
5 |
6 | export const LOGOUT_REQUESTED = 'LOGOUT_REQUESTED';
7 | export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
8 |
9 | export const SIGNUP_REQUESTED = 'SIGNUP_REQUESTED';
10 | export const SIGNUP_STARTED = 'SIGNUP_STARTED';
11 | export const SIGNUP_SUCCESS = 'SIGNUP_SUCCESS';
12 | export const SIGNUP_ERROR = 'SIGNUP_ERROR';
13 |
14 | export const LOGIN_REQUESTED = 'LOGIN_REQUESTED';
15 | export const LOGIN_STARTED = 'LOGIN_STARTED';
16 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
17 | export const LOGIN_ERROR = 'LOGIN_ERROR';
18 |
19 | export const LOAD_USERS_REQUESTED = 'LOAD_USERS_REQUESTED';
20 | export const LOAD_USERS_STARTED = 'LOAD_USERS_STARTED';
21 | export const LOAD_USERS_SUCCESS = 'LOAD_USERS_SUCCESS';
22 | export const LOAD_USERS_ERROR = 'LOAD_USERS_ERROR';
23 |
--------------------------------------------------------------------------------
/src/pages/__tests__/UsersList.js:
--------------------------------------------------------------------------------
1 | import UsersListPage from '../UsersList/UsersList';
2 | import { mountPage } from './helpers';
3 |
4 |
5 | describe('UsersList page', () => {
6 |
7 | const populatedState = {
8 | user: {
9 | users: {
10 | users: [
11 | { email: 'test1@gmail.com' },
12 | { email: 'test2@gmail.com' },
13 | ],
14 | },
15 | },
16 | };
17 | let usersListPage = null;
18 | beforeAll((done) => {
19 | usersListPage = mountPage(UsersListPage, populatedState);
20 | done();
21 | });
22 |
23 | test('Should mount and render', () => {
24 | expect(usersListPage).toEqual(expect.anything());
25 | });
26 |
27 | test('Shows a list of users', () => {
28 | const items = [
29 | usersListPage.find('[data-test="userListItem"]').at(0),
30 | usersListPage.find('[data-test="userListItem"]').at(1),
31 | ];
32 | expect(items[0].contains('test1@gmail.com')).toBe(true);
33 | expect(items[1].contains('test2@gmail.com')).toBe(true);
34 | });
35 |
36 | });
37 |
--------------------------------------------------------------------------------
/src/redux/demo/reducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | INCREMENT_COUNTER, DECREMENT_COUNTER,
3 | LOAD_DATA_STARTED, LOAD_DATA_SUCCESS, LOAD_DATA_ERROR,
4 | } from 'redux/demo/actions';
5 |
6 |
7 | export const STORE_KEY = 'demo';
8 | const initialState = {
9 | count: 0,
10 | posts: [],
11 | postsLoading: false,
12 | postsError: null,
13 | };
14 |
15 | export default ( state = initialState, action ) => {
16 | switch ( action.type ) {
17 | case INCREMENT_COUNTER: {
18 | return {
19 | ...state,
20 | count: state.count + 1,
21 | };
22 | }
23 | case DECREMENT_COUNTER: {
24 | return {
25 | ...state,
26 | count: state.count - 1,
27 | };
28 | }
29 | case LOAD_DATA_STARTED: {
30 | return {
31 | ...state,
32 | postsLoading: true,
33 | };
34 | }
35 | case LOAD_DATA_SUCCESS: {
36 | return {
37 | ...state,
38 | postsLoading: false,
39 | posts: action.payload,
40 | };
41 | }
42 | case LOAD_DATA_ERROR: {
43 | return {
44 | ...state,
45 | postsLoading: false,
46 | postsError: action.payload,
47 | };
48 | }
49 | default: {
50 | return state;
51 | }
52 | }
53 | };
54 |
55 |
--------------------------------------------------------------------------------
/src/components/__tests__/Navbar.js:
--------------------------------------------------------------------------------
1 | import Navbar from 'components/Navbar/Navbar';
2 | import { mountComponent } from 'pages/__tests__/helpers';
3 |
4 |
5 | describe('Navbar component', () => {
6 |
7 |
8 | test('Should mount and render', () => {
9 | const navbar = mountComponent(Navbar, {});
10 | expect(navbar).toEqual(expect.anything());
11 | });
12 |
13 | test('Should show the logged in user when logged in', () => {
14 | const loggedInState = {
15 | user: {
16 | user: {
17 | user: {
18 | email: 'testuser@gmail.com',
19 | },
20 | loading: false,
21 | error: null,
22 | },
23 | },
24 | };
25 | const navbar = mountComponent(Navbar, loggedInState);
26 | expect(navbar.contains('testuser')).toBe(true);
27 | });
28 |
29 | test('Should show the login and signup buttons when logged out', () => {
30 | const loggedOutState = {
31 | user: {
32 | user: {
33 | user: null,
34 | loading: false,
35 | error: null,
36 | },
37 | },
38 | };
39 | const navbar = mountComponent(Navbar, loggedOutState);
40 | expect(navbar.contains('Login')).toBe(true);
41 | expect(navbar.contains('Signup')).toBe(true);
42 | });
43 |
44 | });
45 |
--------------------------------------------------------------------------------
/src/pages/__tests__/Login.js:
--------------------------------------------------------------------------------
1 | import LoginPage from '../Login/Login';
2 | import { mountPage } from './helpers';
3 |
4 |
5 | describe('Login page', () => {
6 |
7 | let loginPage = null;
8 | beforeAll((done) => {
9 | loginPage = mountPage(LoginPage, {});
10 | done();
11 | });
12 |
13 | test('Should mount and render', () => {
14 | expect(loginPage).toEqual(expect.anything());
15 | });
16 |
17 | test('Shows a form', () => {
18 | expect(loginPage.find('form[data-test="loginForm"]').exists()).toBe(true);
19 | });
20 |
21 | test('Shows an error message on submit fail', ( done ) => {
22 | const errorResponse = [
23 | JSON.stringify({
24 | error: {
25 | message: 'Email not found',
26 | },
27 | }),
28 | { status: 404 },
29 | ];
30 | fetch.mockResponseOnce(...errorResponse);
31 |
32 | loginPage.find('input[name="email"]')
33 | .simulate('change', { target: { value: 'abcdefg@gmail.com' } });
34 | loginPage.find('input[name="password"]')
35 | .simulate('change', { target: { value: '12345678' } });
36 | loginPage.find('form[data-test="loginForm"]').simulate('submit');
37 | setTimeout(() => {
38 | loginPage.update();
39 | expect(
40 | loginPage
41 | .find('[data-test="serverError"]').first()
42 | .contains('Email not found')
43 | ).toBe(true);
44 | done();
45 | }, 100);
46 | });
47 |
48 | });
49 |
--------------------------------------------------------------------------------
/src/pages/__tests__/Signup.js:
--------------------------------------------------------------------------------
1 | import SignupPage from '../Signup/Signup';
2 | import { mountPage } from './helpers';
3 |
4 |
5 | describe('Signup page', () => {
6 |
7 | let signupPage = null;
8 | beforeAll((done) => {
9 | signupPage = mountPage(SignupPage, {});
10 | done();
11 | });
12 |
13 | test('Should mount and render', () => {
14 | expect(signupPage).toEqual(expect.anything());
15 | });
16 |
17 | test('Shows a form', () => {
18 | expect(signupPage.find('form[data-test="signupForm"]').exists()).toBe(true);
19 | });
20 |
21 | test('Shows an error message on submit fail', (done) => {
22 | const errorResponse = [
23 | JSON.stringify({
24 | error: {
25 | message: 'User email already in use',
26 | },
27 | }),
28 | { status: 404 },
29 | ];
30 | fetch.mockResponseOnce(...errorResponse);
31 | signupPage.find('input[name="email"]')
32 | .simulate('change', { target: { value: 'abcdefg@gmail.com' } });
33 | signupPage.find('input[name="password"]')
34 | .simulate('change', { target: { value: '12345678' } });
35 | signupPage.find('form[data-test="signupForm"]').simulate('submit');
36 |
37 | setTimeout(() => {
38 | signupPage.update();
39 | expect(
40 | signupPage
41 | .find('[data-test="serverError"]').first()
42 | .contains('User email already in use')
43 | ).toBe(true);
44 | done();
45 | }, 100);
46 |
47 | });
48 |
49 | });
50 |
--------------------------------------------------------------------------------
/src/redux/demo/saga.js:
--------------------------------------------------------------------------------
1 | import { put, call, fork, all } from 'redux-saga/effects';
2 | import { delay, takeOne } from 'redux/sagaHelpers';
3 | import {
4 | INCREMENT_COUNTER, DECREMENT_COUNTER,
5 | INCREMENT_COUNTER_ASYNC, DECREMENT_COUNTER_ASYNC,
6 | LOAD_DATA_REQUESTED, LOAD_DATA_STARTED, LOAD_DATA_SUCCESS, LOAD_DATA_ERROR,
7 | } from 'redux/demo/actions';
8 |
9 |
10 | function* incrementCounterAsync(/* ...args */) {
11 | yield call(delay(1000));
12 | yield put({ type: INCREMENT_COUNTER });
13 | }
14 |
15 | function* decrementCounterAsync(/* ...args */) {
16 | yield call(delay(1000));
17 | yield put({ type: DECREMENT_COUNTER });
18 | }
19 |
20 | function* loadData(action, context) {
21 | yield put({ type: LOAD_DATA_STARTED });
22 | try {
23 | const posts = yield call(
24 | context.request,
25 | 'https://jsonplaceholder.typicode.com/posts',
26 | { query: { _limit: 5 } },
27 | );
28 | yield put({ type: LOAD_DATA_SUCCESS, payload: posts });
29 | }
30 | catch ( httpError ) {
31 | const errorMessage = httpError.error ? httpError.error : httpError.message;
32 | yield put({ type: LOAD_DATA_ERROR, payload: errorMessage });
33 | }
34 | }
35 |
36 | export default function* ( context ) {
37 | yield all([
38 | fork( takeOne(INCREMENT_COUNTER_ASYNC, incrementCounterAsync, context) ),
39 | fork( takeOne(DECREMENT_COUNTER_ASYNC, decrementCounterAsync, context) ),
40 | fork( takeOne(LOAD_DATA_REQUESTED, loadData, context) ),
41 | ]);
42 | }
43 |
--------------------------------------------------------------------------------
/src/pages/Home/Home.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import Link from 'redux-first-router-link';
5 | import { extractUserState } from 'redux/user/reducer';
6 |
7 | import styles from 'pages/Home/Home.scss';
8 | import Typography from '@material-ui/core/Typography';
9 | import Button from '@material-ui/core/Button';
10 |
11 | @connect(
12 | ( globalState ) => ({
13 | user: extractUserState(globalState).user,
14 | })
15 | )
16 | class HomePage extends Component { // eslint-disable-line react/prefer-stateless-function
17 | static propTypes = {
18 | user: PropTypes.object,
19 | }
20 | static defaultProps = {
21 | user: null,
22 | }
23 |
24 | render() {
25 | const { user } = this.props;
26 |
27 | return (
28 |
29 |
30 | ⚫ Universal web boilerplate
31 |
32 |
33 | { user &&
34 | `You are logged in as ${user.email}.`
35 | }
36 | { !user &&
37 |
38 | Sign up to get started
39 |
40 | }
41 |
42 |
43 |
44 |
47 |
48 |
49 | );
50 | }
51 | }
52 |
53 | export default HomePage;
54 |
--------------------------------------------------------------------------------
/src/helpers/request.js:
--------------------------------------------------------------------------------
1 | import lodashGet from 'lodash/get';
2 | import querystring from 'querystring';
3 |
4 |
5 | function getPath( req, url, query ) {
6 | const queryString = query ? ('?' + querystring.stringify(query)) : '';
7 | // NOTE: Use full url if it starts with http
8 | if ( /http/.test(url) ) {
9 | return url + queryString;
10 | }
11 | const basePath = req ? (`${req.protocol}://${req.get('host')}`) : '';
12 | return basePath + url + queryString;
13 | }
14 |
15 | const makeRequest = (req) => (url, options = {}) => {
16 | const path = getPath(req, url, options.query);
17 | const fetchOptions = {
18 | credentials: 'include',
19 | headers: {
20 | 'Content-Type': 'application/json',
21 | },
22 | ...options,
23 | };
24 | if ( options.body && typeof options.body === 'object' ) {
25 | fetchOptions.body = JSON.stringify(options.body);
26 | }
27 | if ( req && lodashGet(req, 'headers.cookie') ) {
28 | fetchOptions.headers['cookie'] = req.headers.cookie;
29 | }
30 |
31 | let responseStatus;
32 | let responseOk;
33 | return fetch(path, fetchOptions)
34 | .then((response) => {
35 | responseOk = response.ok;
36 | responseStatus = response.status;
37 | return response.json();
38 | })
39 | .then((body) => {
40 | if ( !responseOk ) {
41 | return Promise.reject({
42 | ...body,
43 | status: responseStatus,
44 | });
45 | }
46 | return body;
47 | })
48 | .catch((error) => {
49 | return Promise.reject(error);
50 | });
51 | };
52 |
53 | export default makeRequest;
54 |
55 |
--------------------------------------------------------------------------------
/src/pages/__tests__/helpers/index.js:
--------------------------------------------------------------------------------
1 | import { mount } from 'enzyme';
2 | import React from 'react';
3 | import createMemoryHistory from 'history/createMemoryHistory';
4 | import configureStore from 'redux/configureStore';
5 | import createTheme from 'helpers/createTheme';
6 | import makeRequest from 'helpers/request';
7 | import { MuiThemeProvider } from '@material-ui/core/styles';
8 | import CssBaseline from '@material-ui/core/CssBaseline';
9 | import { Provider as ReduxStoreProvider } from 'react-redux';
10 | import PageLayout from 'components/PageLayout/PageLayout';
11 |
12 |
13 | export function wrapWithProviders( Component, initialState ) {
14 | const theme = createTheme();
15 | const request = makeRequest();
16 | const history = createMemoryHistory({ initialEntries: [ '/' ] });
17 | const {
18 | store,
19 | routeInitialDispatch,
20 | } = configureStore(initialState, request, history);
21 | routeInitialDispatch();
22 |
23 | return (
24 |
25 |
26 |
27 | { Component }
28 |
29 |
30 | );
31 | }
32 |
33 | export function mountComponent( RawComponent, initialState ) { // eslint-disable-line
34 | return mount(wrapWithProviders(
35 | ,
36 | initialState,
37 | ));
38 | }
39 |
40 | export function mountPage( PageComponent, initialState ) { // eslint-disable-line
41 | return mount(wrapWithProviders(
42 |
43 |
44 | ,
45 | initialState,
46 | ));
47 | }
48 |
--------------------------------------------------------------------------------
/src/client.js:
--------------------------------------------------------------------------------
1 | // NOTE: This is the entry point for the client render
2 | import React from 'react';
3 | import ReactDOM from 'react-dom';
4 | import AppContainer from 'react-hot-loader/lib/AppContainer';
5 | import App from 'components/App/App';
6 | import CssBaseline from '@material-ui/core/CssBaseline';
7 | import { MuiThemeProvider } from '@material-ui/core/styles';
8 | import createTheme from 'helpers/createTheme';
9 | import configureStore from 'redux/configureStore';
10 | import { Provider as ReduxStoreProvider } from 'react-redux';
11 | import makeRequest from 'helpers/request';
12 | import createBrowserHistory from 'history/createBrowserHistory';
13 |
14 |
15 | const theme = createTheme();
16 | const request = makeRequest();
17 | const history = createBrowserHistory();
18 | const { store } = configureStore(window.__INITIAL_STATE__, request, history);
19 |
20 | if ( process.env.NODE_ENV !== 'production' ) {
21 | window.request = request;
22 | window.store = store;
23 | }
24 |
25 | function render( App ) { // eslint-disable-line no-shadow
26 | const root = document.getElementById('root');
27 | ReactDOM.hydrate(
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | ,
36 | root,
37 | );
38 | }
39 |
40 | render(App);
41 |
42 | if (module.hot) {
43 | module.hot.accept('./components/App/App', () => {
44 | const App = require('./components/App/App').default; // eslint-disable-line no-shadow
45 | render(App);
46 | });
47 | }
48 |
--------------------------------------------------------------------------------
/src/pages/UsersList/UsersList.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import Link from 'redux-first-router-link';
5 |
6 | import List from '@material-ui/core/List';
7 | import ListItem from '@material-ui/core/ListItem';
8 | import ListItemText from '@material-ui/core/ListItemText';
9 | import Typography from '@material-ui/core/Typography';
10 | import Divider from '@material-ui/core/Divider';
11 | import {
12 | extractUsersState,
13 | } from 'redux/user/reducer';
14 | import Loading from 'components/Loading/Loading';
15 |
16 |
17 | @connect(
18 | (globalState) => ({
19 | users: extractUsersState(globalState),
20 | })
21 | )
22 | class UsersList extends Component {
23 | static propTypes = {
24 | users: PropTypes.object.isRequired,
25 | };
26 |
27 | render() {
28 | const {
29 | users,
30 | } = this.props.users;
31 |
32 | return (
33 |
34 | { !users &&
35 |
36 | }
37 | { users &&
38 |
39 |
40 | Users List
41 |
42 |
43 |
44 | { users && users.map((user) => (
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | ))}
55 |
56 |
57 | }
58 |
59 | );
60 | }
61 | }
62 |
63 | export default UsersList;
64 |
--------------------------------------------------------------------------------
/src/components/Page/Page.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { NOT_FOUND } from 'redux-first-router';
5 | import universal from 'react-universal-component';
6 | import Loading from 'components/Loading/Loading';
7 |
8 | import {
9 | ROUTE_HOME,
10 | ROUTE_LOGIN,
11 | ROUTE_SIGNUP,
12 | ROUTE_REDUX_DEMO,
13 | ROUTE_USERS,
14 | ROUTE_USER_DETAIL,
15 | ROUTE_USER_DETAIL_TAB,
16 | ROUTE_ADMIN_USERS,
17 | } from 'redux/routesMap';
18 |
19 |
20 | const options = {
21 | minDelay: 300,
22 | loading: Loading,
23 | };
24 | const HomePage = universal(import('pages/Home/Home'), options);
25 | const LoginPage = universal(import('pages/Login/Login'), options);
26 | const SignupPage = universal(import('pages/Signup/Signup'), options);
27 | const NotFoundPage = universal(import('pages/NotFound/NotFound'), options);
28 | const ReduxDemoPage = universal(import('pages/ReduxDemo/ReduxDemo'), options);
29 | const UsersListPage = universal(import('pages/UsersList/UsersList'), options);
30 | const UserDetailPage = universal(import('pages/UserDetail/UserDetail'), options);
31 |
32 | const actionToPage = {
33 | [ROUTE_HOME]: HomePage,
34 | [ROUTE_LOGIN]: LoginPage,
35 | [ROUTE_SIGNUP]: SignupPage,
36 | [ROUTE_REDUX_DEMO]: ReduxDemoPage,
37 | [ROUTE_USERS]: UsersListPage,
38 | [ROUTE_USER_DETAIL]: UserDetailPage,
39 | [ROUTE_USER_DETAIL_TAB]: UserDetailPage,
40 | [ROUTE_ADMIN_USERS]: UsersListPage,
41 | [NOT_FOUND]: NotFoundPage,
42 | };
43 | const getPageFromRoute = ( routeAction ) => {
44 | let RouteComponent = actionToPage[routeAction];
45 | if ( !RouteComponent ) {
46 | RouteComponent = NotFoundPage;
47 | }
48 | return RouteComponent;
49 | };
50 |
51 | @connect(
52 | (state) => ({
53 | routeAction: state.location.type,
54 | }),
55 | )
56 | class Page extends Component {
57 | static propTypes = {
58 | routeAction: PropTypes.string.isRequired,
59 | }
60 | render() {
61 | const { routeAction } = this.props;
62 | const RoutedPage = getPageFromRoute(routeAction);
63 |
64 | return (
65 |
66 | );
67 | }
68 |
69 | }
70 |
71 | export default Page;
72 |
--------------------------------------------------------------------------------
/src/pages/UserDetail/UserDetail.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import {
5 | extractUserById,
6 | extractUsersState,
7 | } from 'redux/user/reducer';
8 | import { ROUTE_USER_DETAIL_TAB } from 'redux/routesMap';
9 | import Typography from '@material-ui/core/Typography';
10 | import Tabs, { Tab } from '@material-ui/core/Tabs';
11 | import Paper from '@material-ui/core/Paper';
12 | import Loading from 'components/Loading/Loading';
13 |
14 |
15 | @connect(
16 | (globalState) => ({
17 | users: extractUsersState(globalState),
18 | userDetail: extractUserById(globalState, globalState.location.payload.id ),
19 | tabValue: globalState.location.payload.tab || false,
20 | })
21 | )
22 | class UserDetail extends Component {
23 | static propTypes = {
24 | dispatch: PropTypes.func.isRequired,
25 | userDetail: PropTypes.object.isRequired,
26 | users: PropTypes.array.isRequired,
27 | tabValue: PropTypes.oneOf([
28 | 'id', 'email', 'roles', false,
29 | ]).isRequired,
30 | }
31 |
32 | onTabChange = ( event, tabValue ) => {
33 | const {
34 | dispatch,
35 | userDetail,
36 | } = this.props;
37 | dispatch({
38 | type: ROUTE_USER_DETAIL_TAB,
39 | payload: {
40 | id: userDetail._id,
41 | tab: tabValue,
42 | },
43 | });
44 | }
45 |
46 | render() {
47 | const {
48 | userDetail,
49 | users,
50 | tabValue,
51 | } = this.props;
52 |
53 | return (
54 |
55 | { !users &&
56 |
57 | }
58 | { users &&
59 |
60 |
61 | User Detail
62 |
63 |
64 |
65 | { JSON.stringify(userDetail) }
66 |
67 |
68 |
75 |
76 |
77 |
78 |
79 | {tabValue === 'id' &&
{userDetail._id}
}
80 | {tabValue === 'email' &&
{userDetail.email}
}
81 | {tabValue === 'roles' &&
{userDetail.roles.join(', ')}
}
82 |
83 | }
84 |
85 | );
86 | }
87 | }
88 |
89 | export default UserDetail;
90 |
--------------------------------------------------------------------------------
/src/components/Navbar/Navbar.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import Link from 'redux-first-router-link';
5 |
6 | import { extractUserState } from 'redux/user/reducer';
7 | import {
8 | LOGOUT_REQUESTED,
9 | } from 'redux/user/actions';
10 |
11 | import styles from 'components/Navbar/Navbar.scss';
12 | import AppBar from '@material-ui/core/AppBar';
13 | import Toolbar from '@material-ui/core/Toolbar';
14 | import Typography from '@material-ui/core/Typography';
15 | import Button from '@material-ui/core/Button';
16 |
17 |
18 | @connect(
19 | ( globalState ) => ({
20 | user: extractUserState(globalState).user,
21 | })
22 | )
23 | class Navbar extends Component {
24 | static propTypes = {
25 | user: PropTypes.object,
26 | dispatch: PropTypes.func.isRequired,
27 | }
28 | static defaultProps = {
29 | user: null,
30 | }
31 |
32 | logout = () => {
33 | this.props.dispatch({ type: LOGOUT_REQUESTED });
34 | }
35 |
36 | render() {
37 | const {
38 | user,
39 | } = this.props;
40 |
41 | return (
42 |
43 |
44 |
45 |
50 |
57 | ⚪
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | { user &&
67 |
68 |
69 |
74 | { user.email.substr(0, user.email.indexOf('@')) }
75 |
76 |
77 | }
78 | { !user &&
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | }
88 |
89 |
90 |
91 | );
92 | }
93 | }
94 | export default Navbar;
95 |
--------------------------------------------------------------------------------
/src/redux/configureStore.js:
--------------------------------------------------------------------------------
1 | import {
2 | createStore,
3 | applyMiddleware,
4 | compose,
5 | } from 'redux';
6 | import createSagaMiddleware from 'redux-saga';
7 | import createRootReducer from 'redux/rootReducer';
8 | import rootSaga from 'redux/rootSaga';
9 | import { connectRoutes } from 'redux-first-router';
10 | import routesMap, { routeOptions } from 'redux/routesMap';
11 |
12 |
13 | function createReduxLogger() {
14 | let logger = null;
15 | if ( __SERVER__ || __TEST__ ) {
16 | const createLogger = require('redux-cli-logger').default;
17 | logger = createLogger({
18 | downArrow: '▼',
19 | rightArrow: '▶',
20 | log: console.log, // eslint-disable-line no-console
21 | // when non-null, only prints if predicate(getState, action) is truthy
22 | predicate: null,
23 | // useful to trim parts of the state atom that are too verbose
24 | stateTransformer: () => [],
25 | // useful to censor private messages (containing password, etc.)
26 | actionTransformer: (action) => {
27 | // truncate large arrays
28 | if (
29 | Array.isArray(action.payload) &&
30 | action.payload.length > 0
31 | ) {
32 | return {
33 | ...action,
34 | payload: [ action.payload[0], `...${action.payload.length - 1} MORE ITEMS OMITTED` ],
35 | };
36 | }
37 | return action;
38 | },
39 | });
40 | }
41 | if ( __CLIENT__ ) {
42 | const reduxLogger = require('redux-logger');
43 | logger = reduxLogger.createLogger();
44 | }
45 | return logger;
46 | }
47 |
48 | export default (initialState = {}, request, history) => {
49 | const {
50 | reducer: routeReducer,
51 | middleware: routeMiddleware,
52 | enhancer: routeEnhancer,
53 | thunk: routeThunk,
54 | initialDispatch: routeInitialDispatch,
55 | } = connectRoutes(
56 | history,
57 | routesMap,
58 | routeOptions,
59 | );
60 |
61 | const middleware = [];
62 | if (
63 | process.env.NODE_ENV !== 'production' && !__TEST__
64 | ) {
65 | const logger = createReduxLogger();
66 | middleware.push( logger );
67 | }
68 | const sagaMiddleware = createSagaMiddleware();
69 | middleware.push( sagaMiddleware );
70 | middleware.push( routeMiddleware );
71 | const appliedMiddleware = applyMiddleware(...middleware);
72 | const enhancers = compose( routeEnhancer, appliedMiddleware );
73 | const rootReducer = createRootReducer( routeReducer );
74 | const store = createStore(rootReducer, initialState, enhancers);
75 | const rootSagaTask = sagaMiddleware.run(rootSaga, { request });
76 |
77 | if ( module.hot ) {
78 | module.hot.accept('redux/rootReducer', () => {
79 | const _createRootReducer = require('redux/rootReducer').default;
80 | const _rootReducer = _createRootReducer( routeReducer );
81 | store.replaceReducer(_rootReducer);
82 | });
83 | }
84 |
85 | return {
86 | store,
87 | rootSagaTask,
88 | routeThunk,
89 | routeInitialDispatch,
90 | };
91 | };
92 |
--------------------------------------------------------------------------------
/src/pages/Signup/Signup.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { Form, Field } from 'react-final-form';
5 |
6 | import styles from 'pages/Signup/Signup.scss';
7 | import TextInput from 'components/TextInput/TextInput';
8 | import Typography from '@material-ui/core/Typography';
9 | import Button from '@material-ui/core/Button';
10 |
11 | import {
12 | required as isRequired,
13 | email as isEmail,
14 | minLength as isMinLength,
15 | composeValidators,
16 | } from 'helpers/validators';
17 | import {
18 | SIGNUP_REQUESTED,
19 | } from 'redux/user/actions';
20 | import {
21 | extractSignupState,
22 | } from 'redux/user/reducer';
23 |
24 |
25 | @connect(
26 | ( globalState ) => ({
27 | signup: extractSignupState(globalState),
28 | }),
29 | )
30 | class SignupPage extends Component {
31 | static propTypes = {
32 | signup: PropTypes.object.isRequired,
33 | dispatch: PropTypes.func.isRequired,
34 | };
35 |
36 | submitForm = ( values ) => {
37 | this.props.dispatch({ type: SIGNUP_REQUESTED, payload: values });
38 | }
39 |
40 | render() {
41 | const {
42 | signup: { error, loading },
43 | } = this.props;
44 |
45 | return (
46 |
47 |
102 | )}
103 |
104 |
105 | );
106 | }
107 | }
108 |
109 | export default SignupPage;
110 |
--------------------------------------------------------------------------------
/src/pages/Login/Login.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { Form, Field } from 'react-final-form';
5 |
6 | import styles from 'pages/Login/Login.scss';
7 | import TextInput from 'components/TextInput/TextInput';
8 | import Typography from '@material-ui/core/Typography';
9 | import Button from '@material-ui/core/Button';
10 |
11 | import {
12 | required as isRequired,
13 | email as isEmail,
14 | minLength as isMinLength,
15 | composeValidators,
16 | } from 'helpers/validators';
17 | import {
18 | LOGIN_REQUESTED,
19 | } from 'redux/user/actions';
20 | import {
21 | extractLoginState,
22 | } from 'redux/user/reducer';
23 |
24 |
25 | @connect(
26 | ( globalState ) => ({
27 | login: extractLoginState(globalState),
28 | }),
29 | )
30 | class LoginPage extends Component {
31 | static propTypes = {
32 | login: PropTypes.object.isRequired,
33 | dispatch: PropTypes.func.isRequired,
34 | };
35 |
36 | submitForm = ( values ) => {
37 | this.props.dispatch({ type: LOGIN_REQUESTED, payload: values });
38 | }
39 |
40 | render() {
41 | const {
42 | login: { error, loading },
43 | } = this.props;
44 |
45 | return (
46 |
47 |
104 | )}
105 |
106 |
107 | );
108 | }
109 | }
110 |
111 | export default LoginPage;
112 |
--------------------------------------------------------------------------------
/src/helpers/validators.js:
--------------------------------------------------------------------------------
1 | function isEmpty(value) {
2 | return (value === undefined || value === null || value === '');
3 | }
4 |
5 | export function required( value ) {
6 | if ( isEmpty(value) ) {
7 | return 'Required field';
8 | }
9 | return undefined;
10 | }
11 |
12 | export function email(value) {
13 | // http://emailregex.com/
14 | const emailRE = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
15 | if (isEmpty(value) || !emailRE.test(value)) {
16 | return 'Invalid email';
17 | }
18 | return undefined;
19 | }
20 |
21 | export function containsLowerCase(value) {
22 | const test = /[a-z]/.test(value);
23 | if ( !test ) {
24 | return 'Must contain one lowercase letter';
25 | }
26 | return undefined;
27 | }
28 |
29 | export function containsUpperCase(value) {
30 | const test = /[A-Z]/.test(value);
31 | if ( !test ) {
32 | return 'Must contain one uppercase letter';
33 | }
34 | return undefined;
35 | }
36 |
37 | export function containsInteger(value) {
38 | const test = /[0-9]/.test(value);
39 | if ( !test ) {
40 | return 'Must contain one number';
41 | }
42 | return undefined;
43 | }
44 |
45 | export function truthy( value ) {
46 | if ( !value ) {
47 | return 'Must not be blank';
48 | }
49 | return undefined;
50 | }
51 |
52 | export function minLength(min, filterRegex) {
53 | return (value) => {
54 | if ( isEmpty(value) ) return value;
55 | let result = value;
56 | if ( filterRegex ) {
57 | result = result.replace(filterRegex, '');
58 | }
59 | if (result.length < min) {
60 | return `Must contain ${min} or more characters`;
61 | }
62 | return undefined;
63 | };
64 | }
65 |
66 | export function maxLength(max) {
67 | return (value) => {
68 | if (!isEmpty(value) && value.length > max) {
69 | return `Must contain ${max} or fewer characters`;
70 | }
71 | return undefined;
72 | };
73 | }
74 |
75 | export function exactLength(len, filterRegex) {
76 | return (value) => {
77 | if ( isEmpty(value) ) return value;
78 | let result = value;
79 | if ( filterRegex ) {
80 | result = result.replace(filterRegex, '');
81 | }
82 | if (result.length !== len) {
83 | return `Must contain exactly ${len} characters`;
84 | }
85 | return undefined;
86 | };
87 | }
88 |
89 | // check if valid integer or double
90 | export function isNumber( value ) {
91 | if ( isNaN( value ) ) {
92 | return 'Must be a valid number';
93 | }
94 | // check for a case such as `22.`, or `.`
95 | if ( /^([0-9]*\.)$/.test( value ) ) {
96 | return 'Must be a valid number';
97 | }
98 | return undefined;
99 | }
100 |
101 | export function integer(value) {
102 | if (!Number.isInteger(Number(value))) {
103 | return 'Must be a whole number';
104 | }
105 | return undefined;
106 | }
107 |
108 | export function zipcode(value) {
109 | const valueString = ( value && String(value) );
110 | if ( !/^[0-9]{5}$/.test(valueString) ) {
111 | return 'Must be a valid zipcode';
112 | }
113 | return undefined;
114 | }
115 |
116 | // Run one validator after another, return the first error found
117 | export const composeValidators = (...validators) => (value) => {
118 | for ( let i = 0; i < validators.length; i++ ) {
119 | const error = validators[i](value);
120 | if ( error ) {
121 | return error;
122 | }
123 | }
124 | return undefined;
125 | };
126 |
--------------------------------------------------------------------------------
/src/redux/routesMap.js:
--------------------------------------------------------------------------------
1 | import lodashDifference from 'lodash/difference';
2 | import lodashGet from 'lodash/get';
3 | import querySerializer from 'query-string';
4 | import { redirect, NOT_FOUND } from 'redux-first-router';
5 |
6 | export const ROUTE_HOME = 'ROUTE_HOME';
7 | export const ROUTE_LOGIN = 'ROUTE_LOGIN';
8 | export const ROUTE_SIGNUP = 'ROUTE_SIGNUP';
9 | export const ROUTE_REDUX_DEMO = 'ROUTE_REDUX_DEMO';
10 | export const ROUTE_USERS = 'ROUTE_USERS';
11 | export const ROUTE_USER_DETAIL = 'ROUTE_USER_DETAIL';
12 | export const ROUTE_USER_DETAIL_TAB = 'ROUTE_USER_DETAIL_TAB';
13 | export const ROUTE_ADMIN_USERS = 'ROUTE_ADMIN_USERS';
14 |
15 | import {
16 | extractUserState,
17 | } from 'redux/user/reducer';
18 | import {
19 | LOAD_USERS_REQUESTED,
20 | } from 'redux/user/actions';
21 |
22 |
23 | const routesMap = {
24 | [ROUTE_HOME]: {
25 | path: '/',
26 | },
27 | [ROUTE_LOGIN]: {
28 | path: '/login',
29 | loggedOutOnly: true,
30 | },
31 | [ROUTE_SIGNUP]: {
32 | path: '/signup',
33 | loggedOutOnly: true,
34 | },
35 | [ROUTE_REDUX_DEMO]: {
36 | path: '/redux-demo',
37 | },
38 | [ROUTE_USERS]: {
39 | path: '/users',
40 | loggedInOnly: true,
41 | thunk: async (dispatch) => {
42 | dispatch({ type: LOAD_USERS_REQUESTED });
43 | },
44 | },
45 | [ROUTE_USER_DETAIL]: {
46 | path: '/users/:id',
47 | loggedInOnly: true,
48 | thunk: async (dispatch, getState) => {
49 | if ( !getState().user.users.users ) {
50 | dispatch({ type: LOAD_USERS_REQUESTED });
51 | }
52 | },
53 | },
54 | [ROUTE_USER_DETAIL_TAB]: {
55 | path: '/users/:id/:tab',
56 | loggedInOnly: true,
57 | thunk: async (dispatch, getState) => {
58 | if ( !getState().user.users.users ) {
59 | dispatch({ type: LOAD_USERS_REQUESTED });
60 | }
61 | },
62 | },
63 | [ROUTE_ADMIN_USERS]: {
64 | path: '/admin/users',
65 | requireRoles: [ 'admin' ],
66 | thunk: async (dispatch) => {
67 | dispatch({ type: LOAD_USERS_REQUESTED });
68 | },
69 | },
70 | [NOT_FOUND]: {
71 | path: '/not-found',
72 | },
73 | };
74 |
75 | export const routeOptions = {
76 | querySerializer,
77 | // Defer route initial dispatch until after saga is running
78 | initialDispatch: !__SERVER__,
79 | // Check permissions and redirect if not authorized for given route
80 | onBeforeChange: ( dispatch, getState, { action }) => {
81 | const { user } = extractUserState(getState());
82 | const { loggedOutOnly, loggedInOnly, requireRoles } = routesMap[action.type];
83 | const requiresLogin = Boolean( loggedInOnly || requireRoles );
84 |
85 | // redirect to home page if logged in and visiting logged out only routes
86 | if ( loggedOutOnly && user ) {
87 | dispatch( redirect({ type: ROUTE_HOME }) );
88 | return;
89 | }
90 |
91 | if ( requiresLogin && !user ) {
92 | const nextAction = JSON.stringify({
93 | type: action.type,
94 | payload: action.payload,
95 | query: action.meta.location.current.query,
96 | });
97 | dispatch( redirect({
98 | type: ROUTE_LOGIN,
99 | payload: {
100 | query: { next: nextAction },
101 | },
102 | }) );
103 | return;
104 | }
105 |
106 | // redirect to 404 if logged in but invalid role
107 | if ( requireRoles ) {
108 | const userRoles = lodashGet( user, 'roles' );
109 | const hasRequiredRoles = userRoles && lodashDifference(requireRoles, userRoles).length === 0;
110 | if ( !hasRequiredRoles ) {
111 | dispatch( redirect({ type: NOT_FOUND }) );
112 | }
113 | }
114 | },
115 | };
116 |
117 |
118 | export default routesMap;
119 |
--------------------------------------------------------------------------------
/webpack/webpack.server.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Webpack setup based on
3 | *
4 | * survivejs
5 | * https://github.com/survivejs-demos/webpack-demo
6 | *
7 | * react-universal-component
8 | * https://github.com/faceyspacey/redux-first-router-demo
9 | *
10 | * backpack
11 | * https://github.com/jaredpalmer/backpack
12 | *
13 | */
14 |
15 | const fs = require('fs');
16 | const path = require('path');
17 | const webpack = require('webpack');
18 | const webpackMerge = require('webpack-merge');
19 | const parts = require('./webpack.parts');
20 |
21 |
22 | const PATHS = {
23 | src: path.resolve(__dirname, '..', 'src'),
24 | serverEntry: path.resolve(__dirname, '..', 'src', 'server', 'render.js'),
25 | serverBuild: path.resolve(__dirname, '..', 'build', 'server'),
26 | node_modules: path.resolve(__dirname, '..', 'node_modules'),
27 | };
28 |
29 | // if you're specifying externals to leave unbundled, you need to tell Webpack
30 | // to still bundle `react-universal-component`, `webpack-flush-chunks` and
31 | // `require-universal-module` so that they know they are running
32 | // within Webpack and can properly make connections to client modules:
33 | const whitelist = [
34 | '\\.bin',
35 | 'react-universal-component',
36 | 'require-universal-module',
37 | 'webpack-flush-chunks',
38 | ];
39 | const whiteListRE = new RegExp(whitelist.join('|'));
40 | const externals = fs
41 | .readdirSync(PATHS.node_modules)
42 | .filter((x) => !whiteListRE.test(x))
43 | .reduce((_externals, mod) => {
44 | _externals[mod] = `commonjs ${mod}`;
45 | return _externals;
46 | }, {});
47 |
48 | const commonConfig = webpackMerge([
49 | {
50 | // 'server' name required by webpack-hot-server-middleware, see
51 | // https://github.com/60frames/webpack-hot-server-middleware#usage
52 | name: 'server',
53 | target: 'node',
54 | mode: 'none',
55 | bail: true,
56 | entry: [
57 | 'babel-polyfill',
58 | 'fetch-everywhere',
59 | PATHS.serverEntry,
60 | ],
61 | externals,
62 | output: {
63 | path: PATHS.serverBuild,
64 | filename: '[name].js',
65 | libraryTarget: 'commonjs2',
66 | },
67 | resolve: {
68 | modules: [
69 | PATHS.node_modules,
70 | PATHS.src,
71 | ],
72 | },
73 | plugins: [
74 | new webpack.optimize.LimitChunkCountPlugin({
75 | maxChunks: 1,
76 | }),
77 | ],
78 | },
79 | parts.loadJavascript({
80 | include: PATHS.src,
81 | cacheDirectory: false,
82 | }),
83 | parts.serverRenderCSS({
84 | cssModules: true,
85 | exclude: /node_modules/,
86 | }),
87 | ]);
88 |
89 | const developmentConfig = webpackMerge([
90 | {
91 | devtool: 'eval',
92 | output: {
93 | publicPath: '/static/',
94 | },
95 | plugins: [
96 | new webpack.NamedModulesPlugin(),
97 | new webpack.DefinePlugin({
98 | 'process.env': {
99 | NODE_ENV: JSON.stringify('development'),
100 | },
101 | __SERVER__: 'true',
102 | __CLIENT__: 'false',
103 | __TEST__: 'false',
104 | }),
105 | ],
106 | },
107 | ]);
108 |
109 | const productionConfig = webpackMerge([
110 | {
111 | devtool: 'source-map',
112 | plugins: [
113 | new webpack.DefinePlugin({
114 | 'process.env': {
115 | NODE_ENV: JSON.stringify('production'),
116 | },
117 | __SERVER__: 'true',
118 | __CLIENT__: 'false',
119 | __TEST__: 'false',
120 | }),
121 | ],
122 | },
123 | ]);
124 |
125 |
126 | module.exports = ( env ) => {
127 | if ( env === 'production' ) {
128 | return webpackMerge( commonConfig, productionConfig );
129 | }
130 | return webpackMerge( commonConfig, developmentConfig );
131 | };
132 |
--------------------------------------------------------------------------------
/src/redux/user/saga.js:
--------------------------------------------------------------------------------
1 | import lodashGet from 'lodash/get';
2 | import { put, call, fork, all, select } from 'redux-saga/effects';
3 | import { takeOne } from 'redux/sagaHelpers';
4 | import { redirect } from 'redux-first-router';
5 |
6 | import {
7 | LOAD_USER_REQUESTED, LOAD_USER_STARTED, LOAD_USER_SUCCESS, LOAD_USER_ERROR,
8 | LOGOUT_REQUESTED, LOGOUT_SUCCESS,
9 | LOGIN_REQUESTED, LOGIN_STARTED, LOGIN_SUCCESS, LOGIN_ERROR,
10 | SIGNUP_REQUESTED, SIGNUP_STARTED, SIGNUP_SUCCESS, SIGNUP_ERROR,
11 | LOAD_USERS_REQUESTED, LOAD_USERS_STARTED, LOAD_USERS_SUCCESS, LOAD_USERS_ERROR,
12 | } from './actions';
13 | import {
14 | ROUTE_HOME,
15 | } from 'redux/routesMap';
16 |
17 |
18 | function* loadUser(action, context) {
19 | yield put({ type: LOAD_USER_STARTED });
20 | try {
21 | const userData = yield call(context.request, '/api/session');
22 | const userPayload = lodashGet(userData, 'data.currentUser', null);
23 | yield put({ type: LOAD_USER_SUCCESS, payload: userPayload });
24 | }
25 | catch ( httpError ) {
26 | const httpErrorMessage = lodashGet( httpError, 'error.message' );
27 | const errorMessage = httpErrorMessage || httpError.message;
28 | yield put({ type: LOAD_USER_ERROR, payload: errorMessage });
29 | }
30 | }
31 |
32 | function* logout(action, context) {
33 | yield call(context.request, '/api/logout');
34 | yield put({ type: LOGOUT_SUCCESS });
35 | yield put( redirect({ type: ROUTE_HOME }) );
36 | }
37 |
38 | function* login(action, context) {
39 | yield put({ type: LOGIN_STARTED });
40 | try {
41 | yield call( context.request, '/api/login', {
42 | method: 'POST',
43 | body: action.payload,
44 | });
45 | yield put({ type: LOGIN_SUCCESS });
46 | yield* loadUser(null, context);
47 | const globalState = yield select();
48 | const nextAction = lodashGet(globalState, 'location.query.next');
49 | const nextActionParsed = nextAction ? JSON.parse(nextAction) : { type: ROUTE_HOME };
50 | yield put( redirect( nextActionParsed ) );
51 | }
52 | catch ( httpError ) {
53 | const httpErrorMessage = lodashGet( httpError, 'error.message' );
54 | const errorMessage = httpErrorMessage || httpError.message;
55 | yield put({ type: LOGIN_ERROR, payload: errorMessage });
56 | }
57 | }
58 |
59 | function* signup(action, context) {
60 | yield put({ type: SIGNUP_STARTED });
61 | try {
62 | yield call( context.request, '/api/signup', {
63 | method: 'POST',
64 | body: action.payload,
65 | });
66 | yield put({ type: SIGNUP_SUCCESS });
67 | yield* loadUser(null, context);
68 | yield put( redirect({ type: ROUTE_HOME }) );
69 | }
70 | catch ( httpError ) {
71 | const httpErrorMessage = lodashGet( httpError, 'error.message' );
72 | const errorMessage = httpErrorMessage || httpError.message;
73 | yield put({ type: SIGNUP_ERROR, payload: errorMessage });
74 | }
75 | }
76 |
77 | function* loadUsers(action, context) {
78 | yield put({ type: LOAD_USERS_STARTED });
79 | try {
80 | const users = yield call( context.request, '/api/users');
81 | const userList = lodashGet( users, 'data.items' );
82 | yield put({ type: LOAD_USERS_SUCCESS, payload: userList });
83 | }
84 | catch ( httpError ) {
85 | const httpErrorMessage = lodashGet( httpError, 'data.message' );
86 | const errorMessage = httpErrorMessage || httpError.message;
87 | yield put({ type: LOAD_USERS_ERROR, payload: errorMessage });
88 | }
89 | }
90 |
91 | export default function* ( context ) {
92 | yield all([
93 | fork( takeOne( LOAD_USER_REQUESTED, loadUser, context ) ),
94 | fork( takeOne( LOGOUT_REQUESTED, logout, context ) ),
95 | fork( takeOne( LOGIN_REQUESTED, login, context ) ),
96 | fork( takeOne( SIGNUP_REQUESTED, signup, context ) ),
97 | fork( takeOne( LOAD_USERS_REQUESTED, loadUsers, context ) ),
98 | ]);
99 | }
100 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "universal-web-boilerplate",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "repository": "git@github.com:dtonys/universal-web-boilerplate.git",
6 | "author": "dtonys@gmail.com ",
7 | "license": "MIT",
8 | "scripts": {
9 | "dev": "npm-run-all clean dev-server",
10 | "dev-server": "cross-env NODE_ENV=development nodemon --delay 2 --watch src/server --exec babel-node src/server/server.js",
11 | "build": "npm-run-all clean build-client build-server build-node",
12 | "build-client": "cross-env NODE_ENV=production webpack --env production --progress -p --config webpack/webpack.client.config.js",
13 | "build-server": "cross-env NODE_ENV=production webpack --env production --progress -p --config webpack/webpack.server.config.js",
14 | "build-node": "cross-env NODE_ENV=production babel src/server -d build/server",
15 | "start": "cross-env NODE_ENV=production forever start --uid universal-web --append build/server/server.js",
16 | "stop": "forever stop universal-web",
17 | "clean": "rimraf build/client build/server",
18 | "test": "jest --config jest.config.js",
19 | "lint-js": "eslint -c .eslintrc.js ./src"
20 | },
21 | "dependencies": {
22 | "@material-ui/core": "^1.0.0",
23 | "autodll-webpack4-plugin": "^0.4.1",
24 | "autoprefixer": "^8.5.0",
25 | "babel-cli": "^6.26.0",
26 | "babel-core": "^6.26.0",
27 | "babel-jest": "^23.0.1",
28 | "babel-loader": "^7.1.2",
29 | "babel-plugin-module-resolver": "^3.0.0",
30 | "babel-plugin-transform-decorators-legacy": "^1.3.4",
31 | "babel-plugin-transform-runtime": "^6.23.0",
32 | "babel-plugin-universal-import": "3.0.0",
33 | "babel-polyfill": "^6.26.0",
34 | "babel-preset-env": "^1.6.1",
35 | "babel-preset-react": "^6.24.1",
36 | "babel-preset-stage-1": "^6.24.1",
37 | "babel-runtime": "^6.26.0",
38 | "colors": "^1.3.0",
39 | "compression": "^1.7.1",
40 | "cookie-parser": "^1.4.3",
41 | "cross-env": "^5.1.3",
42 | "css-loader": "^0.28.8",
43 | "cssnano": "^3.10.0",
44 | "dotenv": "^5.0.1",
45 | "enzyme": "^3.3.0",
46 | "enzyme-adapter-react-16": "^1.1.1",
47 | "enzyme-to-json": "^3.3.1",
48 | "express": "^4.16.2",
49 | "fast-sass-loader": "^1.4.0",
50 | "fetch-everywhere": "^1.0.5",
51 | "file-loader": "^1.1.6",
52 | "final-form": "^4.0.3",
53 | "forever": "^0.15.3",
54 | "helmet": "^3.9.0",
55 | "history": "^4.7.2",
56 | "http-proxy": "^1.16.2",
57 | "identity-obj-proxy": "^3.0.0",
58 | "jest": "^23.0.1",
59 | "jest-enzyme": "6.0.0",
60 | "jest-fetch-mock": "^1.4.1",
61 | "lodash": "^4.17.4",
62 | "mini-css-extract-plugin": "^0.4.0",
63 | "morgan": "^1.9.0",
64 | "node-sass": "^4.7.2",
65 | "nodemon": "^1.14.11",
66 | "npm-run-all": "^4.1.2",
67 | "optimize-css-assets-webpack-plugin": "4.0.2",
68 | "postcss-loader": "^2.0.10",
69 | "prop-types": "^15.6.0",
70 | "query-string": "6.1.0",
71 | "querystring": "^0.2.0",
72 | "raf": "^3.4.0",
73 | "react": "^16.2.0",
74 | "react-dom": "^16.2.0",
75 | "react-final-form": "^3.0.4",
76 | "react-jss": "^8.2.1",
77 | "react-redux": "^5.0.6",
78 | "react-test-renderer": "^16.2.0",
79 | "react-universal-component": "^2.8.1",
80 | "redux": "^4.0.0",
81 | "redux-cli-logger": "^2.0.1",
82 | "redux-first-router": "^0.0.16-next",
83 | "redux-first-router-link": "^1.4.2",
84 | "redux-logger": "^3.0.6",
85 | "redux-saga": "^0.16.0",
86 | "rimraf": "^2.6.2",
87 | "stats-webpack-plugin": "^0.6.1",
88 | "style-loader": "^0.21.0",
89 | "time-fix-plugin": "^2.0.1",
90 | "uglifyjs-webpack-plugin": "^1.2.5",
91 | "url-loader": "^1.0.1",
92 | "webpack": "4.9.1",
93 | "webpack-cli": "^2.1.4",
94 | "webpack-flush-chunks": "^1.2.3",
95 | "webpack-merge": "^4.1.1"
96 | },
97 | "devDependencies": {
98 | "babel-eslint": "^8.2.1",
99 | "eslint": "^4.15.0",
100 | "eslint-config-airbnb": "^16.1.0",
101 | "eslint-plugin-import": "^2.8.0",
102 | "eslint-plugin-jsx-a11y": "^6.0.3",
103 | "eslint-plugin-react": "^7.5.1",
104 | "react-hot-loader": "3.1.x",
105 | "webpack-dev-middleware": "^3.1.3",
106 | "webpack-hot-middleware": "^2.21.0",
107 | "webpack-hot-server-middleware": "0.5.0",
108 | "write-file-webpack-plugin": "^4.2.0"
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/redux/user/reducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | LOAD_USER_STARTED, LOAD_USER_SUCCESS, LOAD_USER_ERROR,
3 | LOGOUT_SUCCESS,
4 | SIGNUP_STARTED, SIGNUP_SUCCESS, SIGNUP_ERROR,
5 | LOGIN_STARTED, LOGIN_SUCCESS, LOGIN_ERROR,
6 | LOAD_USERS_STARTED, LOAD_USERS_SUCCESS, LOAD_USERS_ERROR,
7 | } from './actions';
8 |
9 |
10 | export const STORE_KEY = 'user';
11 |
12 | export function extractSignupState( globalState ) {
13 | return globalState[STORE_KEY].signup;
14 | }
15 | const signupInitialState = {
16 | loading: false,
17 | error: null,
18 | };
19 | function signupReducer( state = signupInitialState, action ) {
20 | switch ( action.type ) {
21 | case SIGNUP_STARTED: {
22 | return {
23 | ...state,
24 | loading: true,
25 | };
26 | }
27 | case SIGNUP_SUCCESS: {
28 | return {
29 | ...state,
30 | loading: false,
31 | error: null,
32 | };
33 | }
34 | case SIGNUP_ERROR: {
35 | return {
36 | ...state,
37 | loading: false,
38 | error: action.payload,
39 | };
40 | }
41 | default: {
42 | return state;
43 | }
44 | }
45 | }
46 |
47 | export function extractLoginState( globalState ) {
48 | return globalState[STORE_KEY].login;
49 | }
50 | const loginInitialState = {
51 | loading: false,
52 | error: null,
53 | };
54 | function loginReducer( state = loginInitialState, action ) {
55 | switch ( action.type ) {
56 | case LOGIN_STARTED: {
57 | return {
58 | ...state,
59 | loading: true,
60 | };
61 | }
62 | case LOGIN_SUCCESS: {
63 | return {
64 | ...state,
65 | loading: false,
66 | error: null,
67 | };
68 | }
69 | case LOGIN_ERROR: {
70 | return {
71 | ...state,
72 | loading: false,
73 | error: action.payload,
74 | };
75 | }
76 | default: {
77 | return state;
78 | }
79 | }
80 | }
81 |
82 | export function extractUserState( globalState ) {
83 | return globalState[STORE_KEY].user;
84 | }
85 | const initialUserState = {
86 | user: null,
87 | loading: false,
88 | error: null,
89 | };
90 | function userReducer( state = initialUserState, action ) {
91 | switch ( action.type ) {
92 | case LOAD_USER_STARTED: {
93 | return {
94 | ...state,
95 | loading: true,
96 | };
97 | }
98 | case LOAD_USER_SUCCESS: {
99 | return {
100 | ...state,
101 | user: action.payload,
102 | error: null,
103 | loading: false,
104 | };
105 | }
106 | case LOAD_USER_ERROR: {
107 | return {
108 | ...state,
109 | error: action.payload,
110 | loading: false,
111 | };
112 | }
113 | case LOGOUT_SUCCESS: {
114 | return {
115 | ...state,
116 | user: null,
117 | error: null,
118 | };
119 | }
120 | default: {
121 | return state;
122 | }
123 | }
124 | }
125 |
126 |
127 | export function extractUsersState( globalState ) {
128 | return globalState[STORE_KEY].users;
129 | }
130 | export function extractUserById( globalState, id ) {
131 | const users = extractUsersState(globalState).users;
132 | return users.filter((user) => user._id === id)[0];
133 | }
134 | const usersInitialState = {
135 | users: null,
136 | loading: false,
137 | error: null,
138 | };
139 | function usersReducer( state = usersInitialState, action ) {
140 | switch ( action.type ) {
141 | case LOAD_USERS_STARTED: {
142 | return {
143 | ...state,
144 | loading: true,
145 | };
146 | }
147 | case LOAD_USERS_SUCCESS: {
148 | return {
149 | ...state,
150 | loading: false,
151 | users: action.payload,
152 | };
153 | }
154 | case LOAD_USERS_ERROR: {
155 | return {
156 | ...state,
157 | loading: false,
158 | error: action.payload,
159 | };
160 | }
161 | default: {
162 | return state;
163 | }
164 | }
165 | }
166 |
167 |
168 | const initialState = {
169 | user: initialUserState,
170 | signup: signupInitialState,
171 | login: loginInitialState,
172 | users: usersInitialState,
173 | };
174 | export default ( state = initialState, action ) => {
175 | return {
176 | user: userReducer( state.user, action ),
177 | signup: signupReducer( state.signup, action ),
178 | login: loginReducer( state.login, action ),
179 | users: usersReducer( state.users, action ),
180 | };
181 | };
182 |
--------------------------------------------------------------------------------
/src/pages/ReduxDemo/ReduxDemo.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import styles from 'pages/ReduxDemo/ReduxDemo.scss';
4 | import Typography from '@material-ui/core/Typography';
5 | import Button from '@material-ui/core/Button';
6 | import Paper from '@material-ui/core/Paper';
7 | import Grid from '@material-ui/core/Grid';
8 | import CircularProgress from '@material-ui/core/CircularProgress';
9 | import { connect } from 'react-redux';
10 | import {
11 | INCREMENT_COUNTER,
12 | DECREMENT_COUNTER,
13 | INCREMENT_COUNTER_ASYNC,
14 | DECREMENT_COUNTER_ASYNC,
15 | LOAD_DATA_REQUESTED,
16 | } from 'redux/demo/actions';
17 |
18 |
19 | const CounterView = ({
20 | _inc,
21 | _dec,
22 | _incAsync,
23 | _decAsync,
24 | count,
25 | }) => {
26 | return (
27 |
28 |
29 | Count: {count}
30 |
31 |
32 |
35 |
36 |
39 |
40 |
43 |
44 |
47 |
48 | );
49 | };
50 | CounterView.propTypes = {
51 | _inc: PropTypes.func.isRequired,
52 | _dec: PropTypes.func.isRequired,
53 | _incAsync: PropTypes.func.isRequired,
54 | _decAsync: PropTypes.func.isRequired,
55 | count: PropTypes.number.isRequired,
56 | };
57 |
58 |
59 | const LoadDataView = ({
60 | loadData,
61 | posts,
62 | postsLoading,
63 | }) => {
64 | return (
65 |
66 |
69 |
70 | { postsLoading &&
71 |
72 |
73 |
74 | }
75 | { !postsLoading &&
76 |
77 | {JSON.stringify(posts)}
78 |
79 | }
80 |
81 | );
82 | };
83 | LoadDataView.propTypes = {
84 | loadData: PropTypes.func.isRequired,
85 | posts: PropTypes.array.isRequired,
86 | postsLoading: PropTypes.bool.isRequired,
87 | };
88 |
89 | @connect(
90 | (globalState) => ({
91 | count: globalState.demo.count,
92 | posts: globalState.demo.posts,
93 | postsLoading: globalState.demo.postsLoading,
94 | })
95 | )
96 | class ReduxDemo extends Component {
97 | static propTypes = {
98 | count: PropTypes.number.isRequired,
99 | posts: PropTypes.array.isRequired,
100 | postsLoading: PropTypes.bool.isRequired,
101 | dispatch: PropTypes.func.isRequired,
102 | }
103 |
104 | _inc = () => {
105 | this.props.dispatch({ type: INCREMENT_COUNTER });
106 | }
107 |
108 | _dec = () => {
109 | this.props.dispatch({ type: DECREMENT_COUNTER });
110 | }
111 |
112 | _incAsync = () => {
113 | this.props.dispatch({ type: INCREMENT_COUNTER_ASYNC });
114 | }
115 |
116 | _decAsync = () => {
117 | this.props.dispatch({ type: DECREMENT_COUNTER_ASYNC });
118 | }
119 |
120 | loadData = () => {
121 | this.props.dispatch({ type: LOAD_DATA_REQUESTED });
122 | }
123 |
124 | render() {
125 | const {
126 | count,
127 | posts,
128 | postsLoading,
129 | } = this.props;
130 |
131 | return (
132 |
133 |
134 | Redux Demo Page
135 |
136 |
137 | Click the buttons below to change the state
138 |
139 |
140 |
141 |
142 |
149 |
150 |
151 |
156 |
157 |
158 |
159 | );
160 | }
161 | }
162 |
163 | export default ReduxDemo;
164 |
--------------------------------------------------------------------------------
/webpack/webpack.parts.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Webpack parts based on survivejs book and demo repo
3 | *
4 | * survivejs
5 | * https://github.com/survivejs-demos/webpack-demo
6 | *
7 | */
8 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
9 |
10 |
11 | exports.loadJavascript = ({ include, exclude, cacheDirectory } = {}) => ({
12 | module: {
13 | rules: [
14 | {
15 | test: /\.js$/,
16 | include,
17 | exclude,
18 |
19 | use: {
20 | loader: 'babel-loader',
21 | options: {
22 | babelrc: true,
23 | // Enable caching for improved performance during
24 | // development.
25 | // It uses default OS directory by default. If you need
26 | // something more custom, pass a path to it.
27 | // I.e., { cacheDirectory: '' }
28 | cacheDirectory,
29 | },
30 | },
31 | },
32 | ],
33 | },
34 | });
35 |
36 | exports.serverRenderCSS = ({ include, exclude, cssModules } = {}) => ({
37 | module: {
38 | rules: [
39 | {
40 | test: /\.scss$/,
41 | include,
42 | exclude,
43 | use: [
44 | {
45 | loader: 'css-loader/locals',
46 | options: {
47 | modules: cssModules,
48 | localIdentName: '[name]__[local]--[hash:base64:5]',
49 | },
50 | },
51 | {
52 | loader: 'postcss-loader',
53 | options: {
54 | plugins: () => ([
55 | require('autoprefixer')({
56 | browsers: [
57 | '>1%',
58 | 'last 4 versions',
59 | 'Firefox ESR',
60 | 'not ie < 9', // React doesn't support IE8 anyway
61 | ],
62 | flexbox: 'no-2009',
63 | }),
64 | ]),
65 | },
66 | },
67 | {
68 | loader: 'fast-sass-loader',
69 | },
70 | ],
71 | },
72 | ],
73 | },
74 | });
75 |
76 | exports.loadStyles = ({ include, exclude, cssModules } = {}) => ({
77 | module: {
78 | rules: [
79 | {
80 | test: /\.scss$/,
81 | use: [
82 | {
83 | loader: 'style-loader',
84 | },
85 | {
86 | loader: 'css-loader',
87 | options: {
88 | modules: true,
89 | localIdentName: '[name]__[local]--[hash:base64:5]',
90 | },
91 | },
92 | {
93 | loader: 'fast-sass-loader',
94 | },
95 | ],
96 | },
97 | ],
98 | },
99 | });
100 |
101 | exports.extractCSS = ({ include, exclude, cssModules } = {}) => ({
102 | module: {
103 | rules: [
104 | {
105 | test: /\.scss$/,
106 | include,
107 | exclude,
108 | use: [
109 | {
110 | loader: MiniCssExtractPlugin.loader,
111 | },
112 | {
113 | loader: 'css-loader',
114 | options: {
115 | modules: cssModules,
116 | localIdentName: '[name]__[local]--[hash:base64:5]',
117 | },
118 | },
119 | {
120 | loader: 'postcss-loader',
121 | options: {
122 | plugins: () => ([
123 | require('autoprefixer')({
124 | browsers: [
125 | '>1%',
126 | 'last 4 versions',
127 | 'Firefox ESR',
128 | 'not ie < 9', // React doesn't support IE8 anyway
129 | ],
130 | flexbox: 'no-2009',
131 | }),
132 | ]),
133 | },
134 | },
135 | {
136 | loader: 'fast-sass-loader',
137 | },
138 | ],
139 | },
140 | ],
141 | },
142 | plugins: [
143 | new MiniCssExtractPlugin({
144 | filename: '[name].[chunkhash].css',
145 | chunkFilename: '[name].[chunkhash].css',
146 | }),
147 | ],
148 | });
149 |
150 | exports.loadFonts = ({ include, exclude, options } = {}) => ({
151 | module: {
152 | rules: [
153 | {
154 | // Capture eot, ttf, woff, and woff2
155 | test: /\.(eot|ttf|woff|woff2)(\?v=\d+\.\d+\.\d+)?$/,
156 | include,
157 | exclude,
158 |
159 | use: {
160 | loader: 'file-loader',
161 | options,
162 | },
163 | },
164 | ],
165 | },
166 | });
167 |
168 | exports.loadImages = ({ include, exclude, options } = {}) => ({
169 | module: {
170 | rules: [
171 | {
172 | test: /\.(png|jpg|svg)$/,
173 | include,
174 | exclude,
175 |
176 | use: {
177 | loader: 'url-loader',
178 | options,
179 | },
180 | },
181 | ],
182 | },
183 | });
184 |
185 |
--------------------------------------------------------------------------------
/src/server/render.js:
--------------------------------------------------------------------------------
1 | // NOTE: This is the entry point for the server render
2 | import lodashGet from 'lodash/get';
3 | import React from 'react';
4 | import ReactDOM from 'react-dom/server';
5 | import { flushChunkNames } from 'react-universal-component/server';
6 | import flushChunks from 'webpack-flush-chunks';
7 |
8 | import { MuiThemeProvider, createGenerateClassName } from '@material-ui/core/styles';
9 | import CssBaseline from '@material-ui/core/CssBaseline';
10 | import createTheme from 'helpers/createTheme';
11 | import JssProvider from 'react-jss/lib/JssProvider';
12 | import { SheetsRegistry } from 'react-jss/lib/jss';
13 |
14 | import configureStore from 'redux/configureStore';
15 | import createMemoryHistory from 'history/createMemoryHistory';
16 | import { Provider as ReduxStoreProvider } from 'react-redux';
17 | import { END as REDUX_SAGA_END } from 'redux-saga';
18 | import makeRequest from 'helpers/request';
19 | import { LOAD_USER_SUCCESS } from 'redux/user/actions';
20 |
21 | import App from 'components/App/App';
22 |
23 |
24 | function createHtml({
25 | js,
26 | styles,
27 | cssHash,
28 | appString,
29 | muiCss,
30 | initialState,
31 | }) {
32 |
33 | const dllScript = process.env.NODE_ENV !== 'production' ?
34 | '' :
35 | '';
36 |
37 | return `
38 |
39 |
40 |
41 |
42 | universal-web-boilerplate
43 |
44 |
45 |
46 | ${styles}
47 |
48 |
49 |
50 | ${appString}
51 | ${cssHash}
52 | ${js}
53 |
54 |
55 | `;
56 | }
57 |
58 | function renderApp( sheetsRegistry, store ) {
59 | const generateClassName = createGenerateClassName();
60 | const theme = createTheme();
61 | const appRoot = (
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | );
71 | return appRoot;
72 | }
73 |
74 | function createServerRenderMiddleware({ clientStats }) {
75 | return async (req, res, next) => {
76 | const sheetsRegistry = new SheetsRegistry();
77 | const request = makeRequest(req);
78 | const history = createMemoryHistory({ initialEntries: [ req.originalUrl ] });
79 | const {
80 | store,
81 | rootSagaTask,
82 | routeThunk,
83 | routeInitialDispatch,
84 | } = configureStore({}, request, history);
85 |
86 | // load initial data - fetch user
87 | try {
88 | const userData = await request('/api/session');
89 | const userPayload = lodashGet(userData, 'data.currentUser', null);
90 | store.dispatch({ type: LOAD_USER_SUCCESS, payload: userPayload });
91 | }
92 | catch ( error ) {
93 | next(error);
94 | return;
95 | }
96 |
97 | // fire off the routing dispatches, including routeThunk
98 | routeInitialDispatch();
99 |
100 | // check for immediate, synchronous redirect
101 | let location = store.getState().location;
102 | if ( location.kind === 'redirect' ) {
103 | res.redirect(302, location.pathname + ( location.search ? `?${location.search}` : '' ) );
104 | return;
105 | }
106 |
107 | // await on route thunk
108 | if ( routeThunk ) {
109 | await routeThunk(store);
110 | }
111 |
112 | // check for redirect triggered later
113 | location = store.getState().location;
114 | if ( location.kind === 'redirect' ) {
115 | res.redirect(302, location.pathname + ( location.search ? `?${location.search}` : '' ) );
116 | return;
117 | }
118 |
119 | // End sagas and wait until done
120 | store.dispatch(REDUX_SAGA_END);
121 | await rootSagaTask.done;
122 |
123 | let appString = null;
124 | try {
125 | const appInstance = renderApp(sheetsRegistry, store);
126 | appString = ReactDOM.renderToString( appInstance );
127 | }
128 | catch ( err ) {
129 | console.log('ReactDOM.renderToString error'); // eslint-disable-line no-console
130 | console.log(err); // eslint-disable-line no-console
131 | next(err);
132 | return;
133 | }
134 | const initialState = store.getState();
135 |
136 | const muiCss = sheetsRegistry.toString();
137 | const chunkNames = flushChunkNames();
138 | const flushed = flushChunks(clientStats, { chunkNames });
139 | const { js, styles, cssHash } = flushed;
140 |
141 | const htmlString = createHtml({
142 | js,
143 | styles,
144 | cssHash,
145 | appString,
146 | muiCss,
147 | initialState,
148 | });
149 | res.send(htmlString);
150 | };
151 | }
152 |
153 | export default createServerRenderMiddleware;
154 |
155 |
--------------------------------------------------------------------------------
/webpack/webpack.client.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Webpack setup based on
3 | *
4 | * survivejs
5 | * https://github.com/survivejs-demos/webpack-demo
6 | *
7 | * react-universal component
8 | * https://github.com/faceyspacey/redux-first-router-demo
9 | *
10 | */
11 |
12 | const path = require('path');
13 | const webpack = require('webpack');
14 | const webpackMerge = require('webpack-merge');
15 | const cssnano = require('cssnano');
16 | const WriteFilePlugin = require('write-file-webpack-plugin');
17 | // const AutoDllPlugin = require('autodll-webpack4-plugin');
18 | const StatsPlugin = require('stats-webpack-plugin');
19 | const TimeFixPlugin = require('time-fix-plugin');
20 | const UglifyWebpackPlugin = require('uglifyjs-webpack-plugin');
21 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
22 | const parts = require('./webpack.parts');
23 |
24 |
25 | const PATHS = {
26 | src: path.resolve(__dirname, '..', 'src'),
27 | clientEntry: path.resolve(__dirname, '..', 'src', 'client.js'),
28 | clientBuild: path.resolve(__dirname, '..', 'build', 'client'),
29 | node_modules: path.resolve(__dirname, '..', 'node_modules'),
30 | };
31 |
32 | const commonConfig = webpackMerge([
33 | {
34 | // Avoid `mode` option, let's explicitly opt in to all of webpack's settings
35 | // using the defaults that `mode` would set for development and production.
36 | mode: 'none',
37 | // 'client' name required by webpack-hot-server-middleware, see
38 | // https://github.com/60frames/webpack-hot-server-middleware#usage
39 | name: 'client',
40 | target: 'web',
41 | bail: true,
42 | output: {
43 | path: PATHS.clientBuild,
44 | publicPath: '/static/',
45 | },
46 | optimization: {
47 | removeAvailableModules: true,
48 | removeEmptyChunks: true,
49 | mergeDuplicateChunks: true,
50 | providedExports: true,
51 | // This config mimics the behavior of webpack 3 w/universal
52 | splitChunks: {
53 | chunks: 'initial',
54 | cacheGroups: {
55 | default: false,
56 | vendors: {
57 | test: /[\\/]node_modules[\\/]/,
58 | name: 'vendor',
59 | },
60 | },
61 | },
62 | // This config mimics the behavior of webpack 3 w/universal
63 | runtimeChunk: {
64 | name: 'bootstrap',
65 | },
66 | },
67 | resolve: {
68 | modules: [
69 | PATHS.node_modules,
70 | PATHS.src,
71 | ],
72 | },
73 | },
74 | parts.loadFonts({
75 | options: {
76 | name: '[name].[hash:8].[ext]',
77 | },
78 | }),
79 | ]);
80 |
81 | const developmentConfig = webpackMerge([
82 | {
83 | cache: true,
84 | entry: [
85 | 'babel-polyfill',
86 | 'fetch-everywhere',
87 | 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000&reload=false&quiet=false&noInfo=false',
88 | 'react-hot-loader/patch',
89 | PATHS.clientEntry,
90 | ],
91 | output: {
92 | filename: '[name].js',
93 | chunkFilename: '[name].js',
94 | pathinfo: true,
95 | },
96 | optimization: {
97 | namedModules: true,
98 | namedChunks: true,
99 | },
100 | plugins: [
101 | new WriteFilePlugin(),
102 | new TimeFixPlugin(),
103 | new webpack.HotModuleReplacementPlugin(),
104 | new webpack.DefinePlugin({
105 | 'process.env': {
106 | NODE_ENV: JSON.stringify('development'),
107 | },
108 | __SERVER__: 'false',
109 | __CLIENT__: 'true',
110 | __TEST__: 'false',
111 | }),
112 | ],
113 | },
114 | parts.loadStyles(),
115 | parts.loadJavascript({
116 | include: PATHS.src,
117 | cacheDirectory: false,
118 | }),
119 | parts.loadImages(),
120 | ]);
121 |
122 | const productionConfig = webpackMerge([
123 | {
124 | devtool: 'source-map',
125 | entry: [
126 | 'babel-polyfill',
127 | 'fetch-everywhere',
128 | PATHS.clientEntry,
129 | ],
130 | output: {
131 | filename: '[name].[chunkhash].js',
132 | chunkFilename: '[name].[chunkhash].js',
133 | },
134 | optimization: {
135 | flagIncludedChunks: true,
136 | occurrenceOrder: true,
137 | usedExports: true,
138 | sideEffects: true,
139 | concatenateModules: true,
140 | noEmitOnErrors: true,
141 | minimizer: [
142 | new UglifyWebpackPlugin({
143 | sourceMap: true,
144 | }),
145 | ],
146 | },
147 | performance: {
148 | hints: 'warning',
149 | },
150 | plugins: [
151 | new StatsPlugin('stats.json'),
152 | new webpack.DefinePlugin({
153 | 'process.env': {
154 | NODE_ENV: JSON.stringify('production'),
155 | },
156 | __SERVER__: 'false',
157 | __CLIENT__: 'true',
158 | __TEST__: 'false',
159 | }),
160 | new webpack.HashedModuleIdsPlugin(),
161 | new OptimizeCSSAssetsPlugin({
162 | cssProcessor: cssnano,
163 | cssProcessorOptions: {
164 | discardComments: {
165 | removeAll: true,
166 | // Run cssnano in safe mode to avoid potentially unsafe transformations.
167 | safe: true,
168 | },
169 | },
170 | canPrint: false,
171 | }),
172 | ],
173 | recordsPath: path.join(__dirname, 'records.json' ),
174 | },
175 | parts.extractCSS({
176 | cssModules: true,
177 | }),
178 | parts.loadJavascript({
179 | include: PATHS.src,
180 | cacheDirectory: false,
181 | }),
182 | parts.loadImages({
183 | options: {
184 | limit: 15000,
185 | name: '[name].[hash:8].[ext]',
186 | },
187 | }),
188 | ]);
189 |
190 | module.exports = ( env ) => {
191 | if ( env === 'production' ) {
192 | return webpackMerge( commonConfig, productionConfig );
193 | }
194 | return webpackMerge( commonConfig, developmentConfig );
195 | };
196 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | 'extends': 'eslint-config-airbnb',
5 | 'env': {
6 | 'browser': true,
7 | 'node': true,
8 | 'jest': true
9 | },
10 | 'parser': 'babel-eslint',
11 | 'rules': {
12 | // eslint-config-airbnb-base/rules/best-practices.js
13 | 'block-scoped-var': 'warn',
14 | 'class-methods-use-this': 'off',
15 | 'curly': ['warn', 'multi-line'],
16 | 'dot-notation': 'off',
17 | 'guard-for-in': 'off',
18 | 'no-case-declarations': 'warn',
19 | 'no-implicit-coercion': ['warn', {
20 | boolean: false,
21 | number: true,
22 | string: true,
23 | allow: [],
24 | }],
25 | 'no-multi-spaces': [
26 | 'warn', {
27 | 'exceptions': {
28 | 'VariableDeclarator': true,
29 | 'ImportDeclaration': true
30 | }
31 | }
32 | ],
33 | 'no-param-reassign': 'off',
34 | 'no-useless-concat': 'off',
35 | 'prefer-destructuring': 'off',
36 | 'no-restricted-globals': 'off',
37 |
38 | // eslint-config-airbnb-base/rules/errors.js
39 | 'no-console': 'warn',
40 | 'prefer-promise-reject-errors': 'off',
41 |
42 | // eslint-config-airbnb-base/rules/node.js
43 | 'global-require': 'off',
44 |
45 | // eslint-config-airbnb-base/rules/style.js
46 | 'array-bracket-spacing': [ 'warn', 'always' ],
47 | 'brace-style': [ 'warn', 'stroustrup', {
48 | 'allowSingleLine': true
49 | }],
50 | 'camelcase': 'warn',
51 | 'comma-dangle': [ 'warn', 'always-multiline' ],
52 | 'computed-property-spacing': 'off',
53 | 'func-names': 'warn',
54 | 'id-length': [
55 | 'warn', {
56 | 'min': 2,
57 | 'exceptions': [ '_', '$', 'i', 'j', 'k', 'x', 'y', 'e', 't' ]
58 | }
59 | ],
60 | 'indent': [ 'warn', 2, {
61 | 'SwitchCase': 1
62 | }],
63 | 'linebreak-style': [ 'warn', 'unix' ],
64 | 'lines-around-directive': 'off',
65 | 'max-len': [ 'warn', 250, 4, { 'ignoreComments': true } ],
66 | 'newline-per-chained-call': 'off',
67 | 'no-bitwise': 'off',
68 | 'no-continue': 'off',
69 | 'no-mixed-operators': 'off',
70 | 'no-multiple-empty-lines': [
71 | 'warn', {
72 | 'max': 2,
73 | 'maxEOF': 1
74 | }
75 | ],
76 | 'no-nested-ternary': 'off',
77 | 'no-plusplus': 'off',
78 | 'no-restricted-syntax': [
79 | 'error',
80 | 'ForInStatement',
81 | 'LabeledStatement',
82 | 'WithStatement',
83 | ],
84 | 'no-trailing-spaces': [
85 | 'warn', {
86 | 'skipBlankLines': true
87 | }
88 | ],
89 | 'no-underscore-dangle': 'off',
90 | 'object-curly-spacing': [ 'warn', 'always' ],
91 | 'padded-blocks': 'off',
92 | 'quotes': [ 'warn', 'single', 'avoid-escape' ],
93 | 'space-before-blocks': 'warn',
94 | 'space-before-function-paren': [ 'warn', {
95 | 'anonymous': 'always',
96 | 'named': 'never'
97 | }],
98 | 'space-in-parens': 'off',
99 | 'spaced-comment': 'warn',
100 | 'keyword-spacing': 'warn',
101 | 'function-paren-newline': ['error', 'consistent'],
102 |
103 | // eslint-config-airbnb-base/rules/variables.js
104 | 'no-shadow': 'warn',
105 | 'no-unused-vars': 'warn',
106 | 'no-use-before-define': 'warn',
107 | 'no-useless-escape': 'off',
108 |
109 | // eslint-config-airbnb-base/rules/es6.js
110 | 'arrow-body-style': 'off',
111 | 'arrow-parens': [ 'warn', 'always' ],
112 | 'no-var': 'warn',
113 | 'object-shorthand': 'off',
114 | 'prefer-arrow-callback': 'warn',
115 | 'prefer-template': 'off',
116 |
117 | // eslint-config-airbnb-base/rules/imports.js
118 | 'import/first': 'off',
119 | 'import/named': 'error',
120 | 'import/namespace': [ 'error', {
121 | 'allowComputed': false
122 | }],
123 | 'import/no-extraneous-dependencies': [ 'error', {
124 | 'devDependencies': true
125 | }],
126 | 'import/newline-after-import': 'off',
127 | 'import/imports-first': 'off',
128 | 'import/no-unresolved': [ 'error', {} ],
129 | 'import/no-named-as-default': 'error',
130 | 'import/extensions': [ 'warn', 'always', {
131 | '': 'never',
132 | 'js': 'never',
133 | }],
134 | 'import/no-deprecated': 'warn',
135 |
136 | // eslint-config-airbnb/rules/react.js
137 | "react/prop-types": "warn",
138 | "react/forbid-prop-types": "off",
139 | "react/forbid-foreign-prop-types": "off",
140 | "react/no-unused-prop-types": "off",
141 | "react/react-in-jsx-scope": "off",
142 | "react/jsx-filename-extension": [ "warn", { "extensions": [ ".js", ".jsx" ] } ],
143 | "react/jsx-uses-react": "error",
144 | "react/jsx-uses-vars": "error",
145 | "react/jsx-quotes": "off",
146 | "react/jsx-first-prop-new-line": "off",
147 | "react/jsx-closing-bracket-location": "off",
148 | "react/jsx-curly-spacing": "off",
149 | "react/jsx-indent": "off",
150 | "react/self-closing-comp": [ "warn", { "component": true, "html": false } ],
151 | "react/no-multi-comp": "off",
152 | "react/sort-comp": "off",
153 | "react/prefer-stateless-function": "warn",
154 | "react/no-children-prop": "off",
155 | "react/no-danger-with-children": "error",
156 | "jsx-quotes": "error",
157 |
158 | // eslint-config-airbnb/rules/react-a11y.js
159 | "jsx-a11y/img-redundant-alt": "off",
160 | "jsx-a11y/alt-text": "off",
161 | "jsx-a11y/anchor-has-content": "off",
162 | "jsx-a11y/anchor-is-valid": "off",
163 | "jsx-a11y/no-static-element-interactions": "off",
164 | "jsx-a11y/label-has-for": "off",
165 | "jsx-a11y/click-events-have-key-events": "off",
166 | "jsx-a11y/accessible-emoji": "off",
167 |
168 | },
169 | 'plugins': [
170 | 'import',
171 | ],
172 | 'settings': {
173 | 'import/ignore': [
174 | 'node_modules',
175 | '\\.(scss|less|css)$',
176 | ],
177 | 'import/resolver': {
178 | 'node': {
179 | 'moduleDirectory': [
180 | 'node_modules',
181 | './src',
182 | ],
183 | },
184 | },
185 | },
186 | "globals": {
187 | "__CLIENT__": true,
188 | "__SERVER__": true,
189 | "__TEST__": true,
190 | "__non_webpack_require__": false,
191 | },
192 | };
193 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # :black_circle: universal-web-boilerplate
2 |
3 | ## Overview
4 |
5 | A modern universal web boilerplate, built on react, redux, webpack, and node. Use as a starting point for server rendered, code split webapps.
6 |
7 | See the demo app [here](http://www.universalboilerplate.com/)
8 |
9 | ## Motivation
10 |
11 | Setting up a modern javascript web project can be time consuming and difficult.
12 |
13 | Frameworks like [nextjs](https://github.com/zeit/next.js/), [create-react-app](https://github.com/facebook/create-react-app), and [razzle](https://github.com/jaredpalmer/razzle) address this complexity by abstracting configurations away with custom scaffolding tools and setup scripts.
14 |
15 | However these frameworks and tools take control away from the developer, and make it more difficult to change or customize the configuration.
16 |
17 | Universal web boilerplate provides an opinionated yet solid foundation you need to get started, without abstracting any of the implementation details away.
18 |
19 | ## Featuring
20 |
21 |
22 | #### Modern javascript
23 | - async / await everywhere, to simplify async control flow and error handling
24 | - react HOCs applied with decorators
25 | - iterators and generators integrated with redux-saga
26 |
27 | #### Rapid developer workflow
28 | - Hot module replacement reloads the source code on both server and client on change
29 | - Changes to the web server picked up and reloaded with nodemon
30 |
31 | #### Production ready
32 | - CSS and Javascipt minified in production
33 | - CommonsChunkPlugin bundles all vendor dependencies together to be browser cached
34 | - Polyfills, autoprefixers, NODE_ENV, and all the other details taken care of
35 |
36 | #### Modern frontend tech
37 | - `react` for declarative UI
38 | - `redux` for explicit state management
39 | - `react-final-form` for simple and high performance form handling
40 | - `redux-saga` for handling async workflows
41 | - `redux-first-router` for handling universal routing
42 | - `material-ui` foundational components, with `css-modules` enabled sass for custom styling
43 | - `fetch-everywhere` for isomorphic API calls
44 |
45 | #### Testing with Jest + Enzyme
46 | - Components and pages are fully mounted, wrapped with redux, emulating the app's natural state
47 | - Components are tested from a user perspective, hitting all parts of the system
48 |
49 |
50 | #### ESLint based on `eslint-config-airbnb` for fine grained control of code syntax and quality
51 | - Optimized for a react frontend environment
52 | - IDE integration highly recommended
53 |
54 | #### Logging and error handling setup from the start
55 | - redux logger implemented on both server and client
56 | - simple http logging with morgan
57 |
58 | ## Code splitting + server rendering
59 |
60 | Universal web boilerplate utilizes the "universal" product line to solve the problem of code splitting + server rendering together.
61 |
62 | It is recommended to read more about [these modules](https://medium.com/faceyspacey). Some short excerpts below are provided to give you a general sense of what is going on.
63 |
64 | - [react-universal-component](https://github.com/faceyspacey/react-universal-component), which loads components on demand on the client, and synchronously loaded on the server.
65 | - [redux-first-router](https://github.com/faceyspacey/redux-first-router), a router which encourages route based data fetching, and redux as the source of truth for route data.
66 |
67 | ## Usage with a JSON API backend
68 |
69 | This app is designed to connect to a live backend, using the API defined [here](https://github.com/dtonys/node-api-boilerplate#api).
70 |
71 | If you do not provide an API_URL, the app will run in offline mode, and you will not be able to log in or sign up.
72 |
73 | Point the API to `http://api.universalboilerplate.com` to use the existing api deployed there.
74 |
75 | You can also run [node-api-boilerplate](https://github.com/dtonys/node-api-boilerplate) locally alongside the web server, which is recommended to get started with full stack work.
76 |
77 | The app assumes your API server is on running on a separate process, and uses a proxy to send requests to the external API.
78 |
79 | This decoupled approach makes the web and api services easier to organize, and provides a more flexible architecture.
80 |
81 |
82 | ## Prerequisites
83 |
84 | - nodejs, latest LTS - https://nodejs.org/en/
85 | - Yarn - https://yarnpkg.com/en/
86 |
87 | ## Setup
88 |
89 | #### Download the repo and install dependencies
90 | `git clone https://github.com/dtonys/universal-web-boilerplate && cd universal-web-boilerplate`
91 |
92 | `yarn`
93 |
94 | #### Create a `.env` file with values below, and add to the project root
95 | NOTE: Substitute your own values as needed
96 | ```
97 | SERVER_PORT=3010
98 | API_URL=http://api.universalboilerplate.com
99 | ```
100 |
101 | #### Start the server in development mode
102 | `npm run dev`
103 |
104 | #### Build and run the server in production mode
105 | `npm run build`
106 |
107 | `npm run start`
108 |
109 | #### Run tests
110 | `npm run test`
111 |
112 | #### Run ESLint
113 | `npm run lint-js`
114 |
115 |
116 | ## External references
117 |
118 | This boilerplate was created with inspiration from the following resources:
119 |
120 | - react-redux-universal-hot-example - https://github.com/erikras/react-redux-universal-hot-example
121 | - survivejs - https://github.com/survivejs-demos/webpack-demo
122 | - redux-first-router-demo - https://github.com/faceyspacey/redux-first-router-demo
123 | - universal-demo - https://github.com/faceyspacey/universal-demo
124 | - egghead-universal-component - https://github.com/timkindberg/egghead-universal-component
125 | - create-react-app - https://github.com/facebook/create-react-app
126 |
127 | ## Updates
128 |
129 | This boilerplate is periodically updated to keep up with the latest tools and best practices.
130 |
131 | **5/21/2018 - Updates**
132 | - Updated all non webpack 4 related dependencies to latest version.
133 |
134 | **5/28/2018 - Webpack 4 Update**
135 | - Updated to webpack 4 + all webpack 4 dependencies.
136 | - Removed a few optimization related features: autodll-webpack-plugin, babel cache
137 |
138 | **TODO( Performance: Incremental rebuild & Total build time )**
139 |
140 | **TODO( Updates: Update to latest universal stack )**
141 |
--------------------------------------------------------------------------------
/src/server/server.js:
--------------------------------------------------------------------------------
1 | import 'fetch-everywhere';
2 | import colors from 'colors/safe';
3 | import express from 'express';
4 | import cookieParser from 'cookie-parser';
5 | import helmet from 'helmet';
6 | import compression from 'compression';
7 | import morgan from 'morgan';
8 |
9 | import webpack from 'webpack';
10 | import webpackDevMiddleware from 'webpack-dev-middleware';
11 | import webpackHotMiddleware from 'webpack-hot-middleware';
12 | import webpackHotServerMiddleware from 'webpack-hot-server-middleware';
13 | import clientConfigFactory from '../../webpack/webpack.client.config';
14 | import serverConfigFactory from '../../webpack/webpack.server.config';
15 | import dotenv from 'dotenv';
16 | import path from 'path';
17 | import createProxy from 'server/apiProxy';
18 |
19 |
20 | const DEV = process.env.NODE_ENV !== 'production';
21 |
22 | function setupWebackDevMiddleware(app) {
23 | const clientConfig = clientConfigFactory('development');
24 | const serverConfig = serverConfigFactory('development');
25 | const multiCompiler = webpack([ clientConfig, serverConfig ]);
26 |
27 | const clientCompiler = multiCompiler.compilers[0];
28 | app.use(webpackDevMiddleware(multiCompiler, {
29 | publicPath: clientConfig.output.publicPath,
30 | }));
31 | app.use(webpackHotMiddleware(clientCompiler));
32 | app.use(webpackHotServerMiddleware(multiCompiler));
33 |
34 | return new Promise((resolve /* ,reject */) => {
35 | multiCompiler.hooks.done.tap('done', resolve);
36 | });
37 | }
38 |
39 | async function setupWebpack( app ) {
40 | const clientConfig = clientConfigFactory('development');
41 | const publicPath = clientConfig.output.publicPath;
42 | const outputPath = clientConfig.output.path;
43 | if ( DEV ) {
44 | await setupWebackDevMiddleware(app);
45 | }
46 | else {
47 | const clientStats = require('../client/stats.json');
48 | const serverRender = require('../server/main.js').default;
49 |
50 | app.use(publicPath, express.static(outputPath));
51 | app.use(serverRender({ clientStats, outputPath }));
52 | }
53 | }
54 |
55 | function handleErrorMiddleware( err, req, res, next ) {
56 | // NOTE: Add additional handling for errors here
57 | console.log(err); // eslint-disable-line no-console
58 | // Pass to express' default error handler, which will return
59 | // `Internal Server Error` when `process.env.NODE_ENV === production` and
60 | // a stack trace otherwise
61 | next(err);
62 | }
63 |
64 | function handleUncaughtErrors() {
65 | process.on('uncaughtException', ( error ) => {
66 | // NOTE: Add additional handling for uncaught exceptions here
67 | console.log('uncaughtException'); // eslint-disable-line no-console
68 | console.log(error); // eslint-disable-line no-console
69 | process.exit(1);
70 | });
71 | // NOTE: Treat promise rejections the same as an uncaught error,
72 | // as both can be invoked by a JS error
73 | process.on('unhandledRejection', ( error ) => {
74 | // NOTE: Add handling for uncaught rejections here
75 | console.log('unhandledRejection'); // eslint-disable-line no-console
76 | console.log(error); // eslint-disable-line no-console
77 | process.exit(1);
78 | });
79 | }
80 |
81 | function startServer( app ) {
82 | return new Promise((resolve, reject) => {
83 | app.listen(process.env.SERVER_PORT, (err) => {
84 | if ( err ) {
85 | console.log(err); // eslint-disable-line no-console
86 | reject(err);
87 | }
88 | handleUncaughtErrors();
89 | console.log(colors.black.bold('⚫⚫')); // eslint-disable-line no-console
90 | console.log(colors.black.bold(`⚫⚫ Web server listening on port ${process.env.SERVER_PORT}...`)); // eslint-disable-line no-console
91 | console.log(colors.black.bold('⚫⚫\n')); // eslint-disable-line no-console
92 | });
93 | });
94 | }
95 |
96 | function loadEnv() {
97 | dotenv.config({
98 | path: path.resolve(__dirname, '../../.env'),
99 | });
100 |
101 | if ( !process.env.SERVER_PORT ) {
102 | console.log('SERVER_PORT not set in .env file, defaulting to 3000'); // eslint-disable-line no-console
103 | process.env.SERVER_PORT = 3000;
104 | }
105 |
106 | if ( !process.env.API_URL ) {
107 | console.log('API_URL not set in .env file'); // eslint-disable-line no-console
108 | }
109 | }
110 |
111 | async function pingApi() {
112 | // Ping API Server
113 | const response = await fetch( process.env.API_URL );
114 | if ( response && response.ok ) {
115 | console.log(colors.black.bold('⚫⚫')); // eslint-disable-line no-console
116 | console.log(colors.black.bold(`⚫⚫ Connected to API server at ${process.env.API_URL}`)); // eslint-disable-line no-console
117 | console.log(colors.black.bold('⚫⚫\n')); // eslint-disable-line no-console
118 | }
119 | else {
120 | throw new Error(`Cannot ping API server at ${process.env.API_URL}. Status: ${response.status}`);
121 | }
122 | }
123 |
124 | async function bootstrap() {
125 | let offlineMode = false;
126 | loadEnv();
127 |
128 | try {
129 | await pingApi();
130 | }
131 | catch ( error ) {
132 | console.log(colors.red.bold('🔴🔴')); // eslint-disable-line no-console
133 | console.log(colors.red.bold('🔴🔴 API not configured, proceeding with offline mode')); // eslint-disable-line no-console
134 | console.log(colors.red.bold('🔴🔴\n')); // eslint-disable-line no-console
135 | offlineMode = true;
136 | }
137 |
138 | const app = express();
139 |
140 | // middleware
141 | app.use( express.static('public') );
142 | app.all('/favicon.*', (req, res) => {
143 | res.status(404).end();
144 | });
145 | app.use(morgan('[:date[iso]] :method :url :status :response-time ms - :res[content-length]'));
146 | app.use(helmet.noSniff());
147 | app.use(helmet.ieNoOpen());
148 | app.use(helmet.hidePoweredBy());
149 | app.use(compression());
150 | app.use(cookieParser());
151 |
152 | app.use(handleErrorMiddleware);
153 |
154 | // Send dummy JSON response if offline
155 | if ( offlineMode ) {
156 | app.all('/api/*', (req, res) => res.send({}));
157 | }
158 | // Proxy to API
159 | app.all('/api/*', createProxy( process.env.API_URL ));
160 |
161 | await setupWebpack(app);
162 | await startServer(app);
163 | }
164 |
165 | bootstrap()
166 | .catch((error) => {
167 | console.log(error); // eslint-disable-line no-console
168 | });
169 |
170 |
--------------------------------------------------------------------------------