13 |
14 | <% if (reduxState) { %>
15 |
18 | <% } %>
19 | <% if (CONFIG) { %>
20 |
23 | <% } %>
24 | <% if (redialProps) { %>
25 |
28 | <% } %>
29 |
30 | <% resources.js.forEach(function(src){ %>
31 |
32 | <% }) %>
33 | <% resources.css.forEach(function(src){ %>
34 |
35 | <% }) %>
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Front.Band
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/eye.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/common/components/FormField/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'withStyles';
4 | import { compose, withProps, pure } from 'recompose';
5 | import ErrorMessages from '@/components/ErrorMessages';
6 | import styles from './styles.scss';
7 |
8 | const FormField = ({ input, meta, showError, inputComponent: InputComponent }) => (
9 |
),
53 | onStarted: () => {
54 | store.dispatch(showLoading());
55 | },
56 | onCompleted: (transition) => {
57 | store.dispatch([
58 | hideLoading(),
59 | ]);
60 | if (transition === 'beforeTransition') {
61 | window.scrollTo(0, 0);
62 | }
63 | },
64 | onAborted: () => {
65 | store.dispatch(hideLoading());
66 | },
67 | onError: () => {
68 | store.dispatch(hideLoading());
69 | },
70 | })
71 | )}
72 | />
73 |
74 |
75 |
76 | );
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/app/common/redux/data/movies.js:
--------------------------------------------------------------------------------
1 | import { handleAction, combineActions } from 'redux-actions';
2 | import { API_URL } from '@/config';
3 | import { normalize } from 'normalizr';
4 | import { createUrl } from '@/helpers/url';
5 | import { movie } from '@/schemas';
6 | import { invoke } from '@/redux/api';
7 |
8 | export const fetchMovies = options => invoke({
9 | endpoint: createUrl(`${API_URL}/movies`, options),
10 | method: 'GET',
11 | headers: {
12 | 'content-type': 'application/json',
13 | },
14 | types: ['movies/FETCH_LIST_REQUEST', {
15 | type: 'movies/FETCH_LIST_SUCCESS',
16 | payload: (action, state, res) => res.json().then(
17 | json => normalize(json.data, [movie])
18 | ),
19 | }, 'movies/FETCH_LIST_FAILURE'],
20 | });
21 |
22 | export const fetchMovie = (movieId, options) => invoke({
23 | endpoint: createUrl(`${API_URL}/movies/${movieId}`, options),
24 | method: 'GET',
25 | headers: {
26 | 'content-type': 'application/json',
27 | },
28 | types: ['movies/FETCH_DETAILS_REQUEST', {
29 | type: 'movies/FETCH_DETAILS_SUCCESS',
30 | payload: (action, state, res) => res.json().then(
31 | json => normalize(json.data, movie)
32 | ),
33 | }, 'movies/FETCH_DETAILS_FAILURE'],
34 | });
35 |
36 | export const createMovie = (body, options) => invoke({
37 | endpoint: createUrl(`${API_URL}/movies`, options),
38 | method: 'POST',
39 | headers: {
40 | 'content-type': 'application/json',
41 | },
42 | body: {
43 | movie: body,
44 | },
45 | types: ['movies/CREATE_REQUEST', {
46 | type: 'movies/CREATE_SUCCESS',
47 | payload: (action, state, res) => res.json().then(
48 | json => normalize(json.data, movie)
49 | ),
50 | }, 'movies/CREATE_FAILURE'],
51 | });
52 |
53 | export const updateMovie = (movieId, body, options) => invoke({
54 | endpoint: createUrl(`${API_URL}/movies/${movieId}`, options),
55 | method: 'PUT',
56 | headers: {
57 | 'content-type': 'application/json',
58 | },
59 | body: {
60 | movie: body,
61 | },
62 | types: ['movies/UPDATE_REQUEST', {
63 | type: 'movies/UPDATE_SUCCESS',
64 | payload: (action, state, res) => res.json().then(
65 | json => normalize(json.data, movie)
66 | ),
67 | }, 'movies/UPDATE_FAILURE'],
68 | });
69 |
70 | export const deleteMovie = (movieId, options) => invoke({
71 | endpoint: createUrl(`${API_URL}/movies/${movieId}`, options),
72 | method: 'DELETE',
73 | headers: {
74 | 'content-type': 'application/json',
75 | },
76 | types: ['movies/DELETE_REQUEST', 'movies/DELETE_SUCCESS', 'movies/DELETE_FAILURE'],
77 | });
78 |
79 | export default handleAction(
80 | combineActions(
81 | 'movies/FETCH_LIST_SUCCESS',
82 | 'movies/FETCH_DETAILS_SUCCESS',
83 | 'movies/CREATE_SUCCESS',
84 | 'movies/UPDATE_SUCCESS'
85 | ),
86 | (state, action) => ({
87 | ...state,
88 | ...action.payload.entities.movies,
89 | }),
90 | {}
91 | );
92 |
--------------------------------------------------------------------------------
/app/server/api/index.js:
--------------------------------------------------------------------------------
1 | import Express from 'express';
2 | import uuid from 'uuid/v4';
3 | import { find, findIndex, omit } from 'lodash';
4 |
5 | const router = new Express.Router();
6 | router.use(Express.json());
7 |
8 | let movies = [
9 | {
10 | id: uuid(),
11 | title: 'The Shawshank Redemption',
12 | year: 1994,
13 | poster:
14 | 'https://ia.media-imdb.com/images/M/MV5BMDFkYTc0MGEtZmNhMC00ZDIzLWFmNTEtODM1ZmRlYWMwMWFmXkEyXkFqcGdeQXVyMTMxODk2OTU@._V1_.jpg',
15 | description:
16 | 'Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency.',
17 | director: 'Frank Darabont',
18 | genres: ['Crime', 'Drama'],
19 | },
20 | {
21 | id: uuid(),
22 | title: 'The Godfather',
23 | year: 1972,
24 | poster:
25 | 'https://ia.media-imdb.com/images/M/MV5BM2MyNjYxNmUtYTAwNi00MTYxLWJmNWYtYzZlODY3ZTk3OTFlXkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_SY1000_CR0,0,704,1000_AL_.jpg',
26 | description:
27 | 'The aging patriarch of an organized crime dynasty transfers control of his clandestine empire to his reluctant son.',
28 | director: 'Francis Ford Coppola',
29 | genres: ['Crime', 'Drama'],
30 | },
31 | {
32 | id: uuid(),
33 | title: 'The Dark Knight',
34 | year: 2008,
35 | poster:
36 | 'https://ia.media-imdb.com/images/M/MV5BMTMxNTMwODM0NF5BMl5BanBnXkFtZTcwODAyMTk2Mw@@._V1_SY1000_CR0,0,675,1000_AL_.jpg',
37 | description:
38 | 'When the menace known as the Joker emerges from his mysterious past, he wreaks havoc and chaos on the people of Gotham, the Dark Knight must accept one of the greatest psychological and physical tests of his ability to fight injustice.',
39 | director: 'Christopher Nolan',
40 | genres: ['Action', 'Crime', 'Drama'],
41 | },
42 | ];
43 |
44 | router.get('/movies', (req, res) => {
45 | res.json({
46 | data: movies,
47 | });
48 | });
49 |
50 | router.post('/movies', (req, res) => {
51 | const newMovie = {
52 | ...req.body.movie,
53 | id: uuid(),
54 | };
55 | movies.push(newMovie);
56 |
57 | res.json({
58 | data: newMovie,
59 | });
60 | });
61 |
62 | router.get('/movies/:id', (req, res) => {
63 | const movie = find(movies, { id: req.params.id });
64 | if (!movie) {
65 | return res.sendStatus(404);
66 | }
67 | return res.json({
68 | data: movie,
69 | });
70 | });
71 |
72 | router.put('/movies/:id', (req, res) => {
73 | const movieIndex = findIndex(movies, { id: req.params.id });
74 |
75 | if (movieIndex === -1) {
76 | return res.sendStatus(404);
77 | }
78 |
79 | const updatedMovie = omit(req.body.movie, ['id']);
80 | movies[movieIndex] = { ...movies[movieIndex], ...updatedMovie };
81 |
82 | return res.json({
83 | data: movies[movieIndex],
84 | });
85 | });
86 |
87 | router.delete('/movies/:id', (req, res) => {
88 | const movie = find(movies, { id: req.params.id });
89 | if (!movie) {
90 | return res.sendStatus(404);
91 | }
92 | movies = movies.filter(i => i.id !== req.params.id);
93 | return res.json({
94 | data: movies,
95 | });
96 | });
97 |
98 | export default router;
99 |
--------------------------------------------------------------------------------
/app/server/page.js:
--------------------------------------------------------------------------------
1 | import Set from 'core-js/library/fn/set';
2 | import arrayFrom from 'core-js/library/fn/array/from';
3 | import CookieDough from 'cookie-dough';
4 |
5 | import React from 'react';
6 | import ReactDOMServer from 'react-dom/server';
7 | import { useRouterHistory, match, Router, applyRouterMiddleware } from 'react-router';
8 |
9 | import Helmet from 'react-helmet';
10 |
11 | import { I18nextProvider } from 'react-i18next';
12 | import { triggerHooks, useRedial } from 'react-router-redial';
13 | import { syncHistoryWithStore } from 'react-router-redux';
14 |
15 | import { Provider } from 'react-redux';
16 |
17 | import createMemoryHistory from 'history/lib/createMemoryHistory';
18 | import useQueries from 'history/lib/useQueries';
19 |
20 | import { configureStore } from '@/store';
21 |
22 | import { configureRoutes } from '@/routes';
23 | import WithStylesContext from '@/WithStylesContext';
24 | import '@/services/validations';
25 |
26 | export default () => (req, res, next) => {
27 | if (__DEV__) {
28 | return res.render('index', {
29 | html: null,
30 | reduxState: null,
31 | inlineCss: null,
32 | redialProps: null,
33 | helmet: Helmet.rewind(),
34 | });
35 | }
36 |
37 | const memoryHistory = useRouterHistory(useQueries(createMemoryHistory))();
38 | const store = configureStore({
39 | history: memoryHistory,
40 | cookies: new CookieDough(req),
41 | i18n: req.i18n,
42 | });
43 | const history = syncHistoryWithStore(memoryHistory, store);
44 | const routes = configureRoutes({
45 | store,
46 | });
47 | const router = { routes };
48 | const historyLocation = history.createLocation(req.url);
49 |
50 | const { dispatch, getState } = store;
51 |
52 | return match({ routes: router, location: historyLocation }, (error, redirectLocation, renderProps) => { //eslint-disable-line
53 | if (redirectLocation) {
54 | return res.redirect(301, redirectLocation.pathname + redirectLocation.search);
55 | } else if (error) {
56 | return res.status(500).send(error.message);
57 | } else if (renderProps == null) {
58 | return res.status(404).send('Not found');
59 | }
60 |
61 | const route = renderProps.routes[renderProps.routes.length - 1];
62 | const locals = {
63 | // Allow lifecycle hooks to dispatch Redux actions:
64 | dispatch,
65 | getState,
66 | };
67 |
68 | // Wait for async data fetching to complete, then render:
69 | return triggerHooks({
70 | renderProps,
71 | locals,
72 | hooks: ['fetch', 'server', 'done'],
73 | }).then(({ redialMap, redialProps }) => {
74 | const reduxState = escape(JSON.stringify(getState()));
75 | const css = new Set();
76 | /* eslint-disable no-underscore-dangle */
77 | let html;
78 | const component = applyRouterMiddleware(useRedial({ redialMap }))(renderProps);
79 |
80 | try {
81 | html = ReactDOMServer.renderToString(
82 |
83 | styles._getCss && css.add(styles._getCss())}
85 | >
86 |
87 | { component }
88 |
89 |
90 |
91 | );
92 | } catch (e) {
93 | console.log('render error');
94 | console.error(e);
95 | html = null;
96 | }
97 |
98 | const helmet = Helmet.rewind();
99 |
100 | res.status(route.status || 200);
101 | res.render('index', {
102 | html,
103 | redialProps: escape(JSON.stringify(redialProps)),
104 | reduxState,
105 | helmet,
106 | inlineCss: arrayFrom(css).join(''),
107 | });
108 | })
109 | .catch(err => next(err));
110 | });
111 | };
112 |
--------------------------------------------------------------------------------
/bin/release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # This script simplifies releasing a new Docker image of your release.
4 | # It will run following steps:
5 | # 1. Create git tag with version number specified in package.json
6 | # 2. Tag Docker container that is created by build.sh script to a Docker Hub repo.
7 | # 3. Upload changes to Docker Hub.
8 | #
9 | # Usage:
10 | # ./bin/release.sh -a DOCKER_HUB_ACCOUNT_NAME [-v RELEASE_VERSION -l -f]
11 | # '-l' - create additional tag :latest.
12 | # '-f' - force tag creating when git working tree is not empty.
13 |
14 | # Find package.json inside project tree.
15 | # This allows to call bash scripts within any folder inside project.
16 | PROJECT_DIR=$(git rev-parse --show-toplevel)
17 | if [ ! -f "${PROJECT_DIR}/package.json" ]; then
18 | echo "[E] Can't find '${PROJECT_DIR}/package.json'"
19 | echo " Check that you run this script inside git repo or init a new one in project root."
20 | fi
21 |
22 | # Extract project name and version from package.json
23 | PROJECT_NAME=$(cat "${PROJECT_DIR}/package.json" \
24 | | grep name \
25 | | head -1 \
26 | | awk -F: '{ print $2 }' \
27 | | sed 's/[",]//g' \
28 | | tr -d '[[:space:]]')
29 | PROJECT_VERSION=$(cat "${PROJECT_DIR}/package.json" \
30 | | grep version \
31 | | head -1 \
32 | | awk -F: '{ print $2 }' \
33 | | sed 's/[",]//g' \
34 | | tr -d '[[:space:]]')
35 | REPO_TAG=$PROJECT_VERSION
36 |
37 | # A POSIX variable
38 | OPTIND=1 # Reset in case getopts has been used previously in the shell.
39 |
40 | # Default settings
41 | IS_LATEST=0
42 |
43 | if git diff-index --quiet HEAD --; then
44 | PASS_GIT=1
45 | # no changes
46 | else
47 | PASS_GIT=0
48 | fi
49 |
50 | # Parse ARGS
51 | while getopts "v:la:ft:" opt; do
52 | case "$opt" in
53 | a) HUB_ACCOUNT=$OPTARG
54 | ;;
55 | v) PROJECT_VERSION=$OPTARG
56 | ;;
57 | t) REPO_TAG=$OPTARG
58 | ;;
59 | l) IS_LATEST=1
60 | ;;
61 | f) PASS_GIT=1
62 | ;;
63 | esac
64 | done
65 |
66 | if [ ! $HUB_ACCOUNT ]; then
67 | echo "[E] You need to specify Docker Hub account with '-a' option."
68 | exit 1
69 | fi
70 |
71 | # Get release notes
72 | PREVIOUS_TAG=$(git describe HEAD^1 --abbrev=0 --tags)
73 | GIT_HISTORY=$(git log --no-merges --format="- %s" $PREVIOUS_TAG..HEAD)
74 |
75 | if [[ $PREVIOUS_TAG == "" ]]; then
76 | GIT_HISTORY=$(git log --no-merges --format="- %s")
77 | fi;
78 |
79 | # Create git tag that matches release version
80 | if [ `git tag --list $PROJECT_VERSION` ]; then
81 | echo "[W] Git tag '${PROJECT_VERSION}' already exists. I won't be created during release."
82 | else
83 | if [ ! $PASS_GIT ]; then
84 | echo "[E] Working tree contains uncommited changes. This may cause wrong relation between image tag and git tag."
85 | echo " You can skip this check with '-f' option."
86 | exit 1
87 | else
88 | echo "[I] Creating git tag '${PROJECT_VERSION}'.."
89 | echo " Release Notes: "
90 | echo $GIT_HISTORY
91 |
92 | git tag -a $PROJECT_VERSION -m "${GIT_HISTORY}"
93 | fi
94 | fi
95 |
96 | if [ "${REPO_TAG}" != "${PROJECT_VERSION}" ]; then
97 | echo "[I] Tagging image '${PROJECT_NAME}:${PROJECT_VERSION}' into a Docker Hub repository '${HUB_ACCOUNT}/${PROJECT_NAME}:${REPO_TAG}'.."
98 | docker tag "${PROJECT_NAME}:${PROJECT_VERSION}" "${HUB_ACCOUNT}/${PROJECT_NAME}:${REPO_TAG}"
99 | fi
100 |
101 | echo "[I] Tagging image '${PROJECT_NAME}:${PROJECT_VERSION}' into a Docker Hub repository '${HUB_ACCOUNT}/${PROJECT_NAME}:${PROJECT_VERSION}'.."
102 | docker tag "${PROJECT_NAME}:${PROJECT_VERSION}" "${HUB_ACCOUNT}/${PROJECT_NAME}:${PROJECT_VERSION}"
103 |
104 | if [ $IS_LATEST == 1 ]; then
105 | echo "[I] Assigning additional tag '${HUB_ACCOUNT}/${PROJECT_NAME}:latest'.."
106 | docker tag "${PROJECT_NAME}:${PROJECT_VERSION}" "${HUB_ACCOUNT}/${PROJECT_NAME}:latest"
107 | fi
108 |
109 | echo "[I] Pushing changes to Docker Hub.."
110 | docker push "${HUB_ACCOUNT}/${PROJECT_NAME}"
111 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-boilerplate",
3 | "version": "0.1.0",
4 | "description": "FrontBand. React boilerplate",
5 | "repository": "https://github.com/FrontBand/react-boilerplate.git",
6 | "scripts": {
7 | "postinstall": "[ $PREBUILD ] && npm run build || exit 0",
8 | "predev": "npm run webpack",
9 | "dev": "concurrently --kill-others \"node static/server.js\" \"node webpack.server.js\"",
10 | "stats": "NODE_ENV=production webpack-dashboard -- node webpack.server.js",
11 | "start": "NODE_ENV=production node --inspect static/server.js",
12 | "webpack": "webpack",
13 | "build": "NODE_ENV=production npm run webpack",
14 | "preproduction": "npm run build",
15 | "production": "NODE_ENV=production node --inspect static/server.js",
16 | "hooks:commit": "lint-staged",
17 | "stylelint": "stylelint 'app/**/*.scss' 'app/**/*.css' 'assets/**/*.scss' 'assets/**/*.css'",
18 | "lint": "eslint app tests && npm run stylelint",
19 | "locales:extract": "i18next-extract-gettext --files='./+(app)/**/*.+(js|json)' --output=app/common/locales/source.pot",
20 | "storybook": "start-storybook -p 9001 -c .storybook",
21 | "test": "jest",
22 | "test:acceptance": "cypress run --browser chrome"
23 | },
24 | "lint-staged": {
25 | "*.js": "eslint app",
26 | "*.@(scss|css)": "stylelint"
27 | },
28 | "config": {
29 | "ghooks": {
30 | "pre-commit": "npm run hooks:commit"
31 | }
32 | },
33 | "browserslist": [
34 | "defaults",
35 | "> 1%",
36 | "iOS >= 7",
37 | "Safari >= 7",
38 | "Safari 8"
39 | ],
40 | "engines": {
41 | "node": ">=7.5.0"
42 | },
43 | "author": "Front.Band (https://front.band/)",
44 | "license": "MIT",
45 | "dependencies": {
46 | "assets-webpack-plugin": "^3.4.0",
47 | "autoprefixer": "^7.1.2",
48 | "babel": "^6.23.0",
49 | "babel-core": "^6.26.3",
50 | "babel-loader": "^7.1.1",
51 | "babel-plugin-module-resolver": "^2.7.0",
52 | "babel-plugin-react-transform": "^2.0.2",
53 | "babel-plugin-transform-decorators-legacy": "^1.3.4",
54 | "babel-plugin-transform-runtime": "^6.23.0",
55 | "babel-polyfill": "^6.23.0",
56 | "babel-preset-es2015": "^6.24.0",
57 | "babel-preset-react": "^6.23.0",
58 | "babel-preset-stage-0": "^6.22.0",
59 | "babel-register": "^6.24.0",
60 | "chai-spies": "^0.7.1",
61 | "classnames": "^2.2.5",
62 | "cookie-dough": "^0.1.0",
63 | "cookie-parser": "^1.4.3",
64 | "core-js": "^2.4.1",
65 | "cross-env": "^2.0.0",
66 | "css-loader": "^0.28.4",
67 | "cypress": "^3.0.1",
68 | "date-fns": "^2.0.0-alpha.1",
69 | "dotenv": "^2.0.0",
70 | "ejs": "^2.4.2",
71 | "eslint-plugin-cypress": "^2.0.1",
72 | "express": "^4.14.0",
73 | "extract-text-webpack-plugin": "^2.1.0",
74 | "file-loader": "^0.11.2",
75 | "history": "^3.0.0",
76 | "i18next": "^8.4.3",
77 | "i18next-browser-languagedetector": "^2.0.0",
78 | "i18next-express-middleware": "^1.0.5",
79 | "i18next-po-loader": "^1.0.0",
80 | "ignore-styles": "^5.0.1",
81 | "isomorphic-style-loader": "^4.0.0",
82 | "json-loader": "^0.5.5",
83 | "lodash": "^4.16.0",
84 | "node-cron": "^1.2.0",
85 | "normalizr": "^3.2.3",
86 | "path": "0.12.7",
87 | "postcss": "^6.0.4",
88 | "postcss-apply": "^0.7.0",
89 | "postcss-css-variables": "^0.7.0",
90 | "postcss-import": "^10.0.0",
91 | "postcss-loader": "^2.0.6",
92 | "postcss-math": "^0.0.8",
93 | "postcss-nested": "^2.0.1",
94 | "precss": "^2.0.0",
95 | "prop-types": "^15.6.1",
96 | "proxy-middleware": "^0.15.0",
97 | "react": "^16.3.0",
98 | "react-dom": "^16.3.2",
99 | "react-helmet": "^5.1.3",
100 | "react-hot-loader": "^3.0.0-beta.6",
101 | "react-i18next": "4.3.0",
102 | "react-nebo15-validate": "^0.1.12",
103 | "react-redux": "^5.0.5",
104 | "react-router": "^3.0.0",
105 | "react-router-redial": "^0.3.4",
106 | "react-router-redux": "^4.0.6",
107 | "react-router-sitemap": "^1.1.1",
108 | "react-svg-loader": "^1.1.1",
109 | "react-svg-sprite-icon": "^0.0.11",
110 | "recompose": "^0.27.0",
111 | "redial": "^0.5.0",
112 | "redux": "^3.7.1",
113 | "redux-actions": "^2.1.0",
114 | "redux-api-middleware": "^1.0.3",
115 | "redux-form": "^7.0.0",
116 | "redux-freeze": "^0.1.4",
117 | "redux-multi": "^0.1.12",
118 | "redux-promise": "^0.5.3",
119 | "redux-thunk": "^2.1.0",
120 | "style-loader": "^0.18.2",
121 | "svg-inline-loader": "^0.8.0",
122 | "svg-inline-react": "^1.0.2",
123 | "uuid": "^3.2.1",
124 | "webfonts-loader": "^1.2.0",
125 | "webpack": "^3.1.0",
126 | "webpack-merge": "^4.1.0"
127 | },
128 | "devDependencies": {
129 | "@storybook/addon-links": "^3.4.2",
130 | "@storybook/addon-options": "^3.4.2",
131 | "@storybook/channels": "^3.4.2",
132 | "@storybook/react": "^3.4.2",
133 | "argos-cli": "^0.0.9",
134 | "babel-eslint": "^6.1.2",
135 | "babel-istanbul-instrumenter-loader": "^1.0.1",
136 | "babel-jest": "^23.0.1",
137 | "babel-preset-react-hmre": "^1.1.1",
138 | "browserstack-local": "^1.2.0",
139 | "chai": "^4.1.0",
140 | "chromedriver": "^2.29.2",
141 | "concurrently": "^3.5.0",
142 | "coveralls": "^2.13.1",
143 | "enzyme": "^2.9.1",
144 | "eslint": "^3.6.0",
145 | "eslint-config-airbnb": "^11.2.0",
146 | "eslint-import-resolver-babel-module": "^2.2.1",
147 | "eslint-plugin-chai-expect": "^1.1.1",
148 | "eslint-plugin-import": "^2.3.0",
149 | "eslint-plugin-jsx-a11y": "^2.2.2",
150 | "eslint-plugin-react": "^6.3.0",
151 | "ghooks": "^1.3.2",
152 | "i18next-extract-gettext": "^3.1.3",
153 | "isparta-loader": "^2.0.0",
154 | "jest": "^23.1.0",
155 | "lint-staged": "^4.0.1",
156 | "react-addons-test-utils": "^15.2.1",
157 | "regenerator-runtime": "^0.11.1",
158 | "stylelint": "~9.2.0",
159 | "stylelint-config-standard": "~18.2.0",
160 | "webpack-dashboard": "^0.4.0",
161 | "webpack-dev-server": "^2.5.1"
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/webpack/parts.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const webpackMerge = require('webpack-merge');
3 |
4 | const ExtractTextPlugin = require('extract-text-webpack-plugin');
5 |
6 | const DEBUG = process.env.NODE_ENV !== 'production';
7 |
8 | const extractStyles = new ExtractTextPlugin('[name].css?[hash]');
9 |
10 | exports.setupJs = () => ({
11 | module: {
12 | rules: [
13 | {
14 | test: /\.js$/,
15 | use: ['babel-loader'],
16 | exclude: /node_modules/,
17 | },
18 | ],
19 | },
20 | });
21 |
22 |
23 | const cssLoader = {
24 | loader: 'css-loader',
25 | options: {
26 | localIdentName: DEBUG ? '[local]__[path][name]__[hash:base64:5]' : '[hash:base64]',
27 | modules: true,
28 | // it doesn't work correctly. It uses cssnano for minification, but do It unsafe.
29 | // For example, It remove -webkit prefix from flex rules. And it breaks support of Safari 8
30 | minimize: false,
31 | },
32 | };
33 |
34 | const scssLoaders = [
35 | cssLoader,
36 | 'postcss-loader',
37 | ];
38 |
39 | const cssLoaders = [
40 | 'css-loader',
41 | ];
42 |
43 | const fontLoaders = [
44 | cssLoader,
45 | {
46 | loader: 'webfonts-loader',
47 | options: {
48 | embed: true,
49 | },
50 | },
51 | ];
52 |
53 | exports.setupCssCritical = () => ({
54 | module: {
55 | rules: [
56 | {
57 | test: /\.scss/,
58 | use: [
59 | 'isomorphic-style-loader',
60 | ].concat(scssLoaders),
61 | },
62 | {
63 | test: /\.css/,
64 | use: [
65 | 'isomorphic-style-loader',
66 | ].concat(cssLoaders),
67 | },
68 | ],
69 | },
70 | });
71 |
72 | exports.setupCss = () => ({
73 | module: {
74 | rules: [
75 | {
76 | test: /\.scss/,
77 | use: [
78 | 'style-loader',
79 | ...scssLoaders,
80 | ],
81 | },
82 | {
83 | test: /\.css/,
84 | use: [
85 | 'style-loader',
86 | ...cssLoaders,
87 | ],
88 | },
89 | ],
90 | },
91 | });
92 |
93 | exports.setupCssExtract = () => ({
94 | module: {
95 | rules: [
96 | {
97 | test: /\.scss/,
98 | use: extractStyles.extract({
99 | use: scssLoaders,
100 | }),
101 | },
102 | {
103 | test: /\.css/,
104 | use: extractStyles.extract({
105 | use: cssLoaders,
106 | }),
107 | },
108 | ],
109 | },
110 | plugins: [
111 | extractStyles,
112 | ],
113 | });
114 |
115 | exports.setupCssIgnore = () => ({
116 | module: {
117 | rules: [
118 | {
119 | test: /\.(scss|css)/,
120 | use: [
121 | 'ignore-loader',
122 | ],
123 | },
124 | ],
125 | },
126 | });
127 |
128 | exports.setupFontGen = () => ({
129 | module: {
130 | rules: [
131 | {
132 | test: /\.font\.(js|json)$/,
133 | use: [
134 | 'style-loader',
135 | ].concat(fontLoaders),
136 | },
137 | ],
138 | },
139 | });
140 |
141 | exports.setupFontGenCritical = () => ({
142 | module: {
143 | rules: [
144 | {
145 | test: /\.font\.(js|json)$/,
146 | use: [
147 | 'isomorphic-style-loader',
148 | ].concat(fontLoaders),
149 | },
150 | ],
151 | },
152 | });
153 |
154 | exports.setupFontGenExtract = () => ({
155 | module: {
156 | rules: [
157 | {
158 | test: /\.font\.(js|json)$/,
159 | use: extractStyles.extract({
160 | use: fontLoaders,
161 | }),
162 | },
163 | ],
164 | },
165 | plugins: [
166 | extractStyles,
167 | ],
168 | });
169 |
170 | exports.setupFont = () => ({
171 | module: {
172 | rules: [
173 | {
174 | test: /\.(woff|woff2|eot|ttf)(\?.*$|$)/,
175 | loader: 'file-loader',
176 | },
177 | ],
178 | },
179 | });
180 |
181 | exports.setupImages = () => ({
182 | module: {
183 | rules: [
184 | {
185 | test: /.*\.(gif|png|svg|jpe?g)$/i,
186 | use: [
187 | {
188 | loader: 'file-loader',
189 | options: {
190 | name: '[hash].[ext]',
191 | },
192 | },
193 | ],
194 | },
195 | ],
196 | },
197 | });
198 |
199 | exports.setupJson = () => ({
200 | module: {
201 | rules: [
202 | {
203 | test: /\.json/i,
204 | loader: 'json-loader',
205 | },
206 | ],
207 | },
208 | });
209 |
210 | exports.setupI18n = () => ({
211 | module: {
212 | rules: [
213 | {
214 | test: /\.po$/,
215 | use: [
216 | 'i18next-po-loader',
217 | ],
218 | },
219 | ],
220 | },
221 | });
222 |
223 | exports.setupProduction = () => ({
224 | plugins: [
225 | new webpack.LoaderOptionsPlugin({
226 | minimize: true,
227 | debug: false,
228 | }),
229 | new webpack.optimize.UglifyJsPlugin({
230 | compress: {
231 | warnings: false,
232 | screw_ie8: true,
233 | conditionals: true,
234 | unused: true,
235 | comparisons: true,
236 | sequences: true,
237 | dead_code: true,
238 | evaluate: true,
239 | if_return: true,
240 | join_vars: true,
241 | },
242 | output: {
243 | comments: false,
244 | },
245 | }),
246 | ],
247 | });
248 |
249 | exports.setupHotReload = (config, port = 3030) => {
250 | const resConfig = webpackMerge(
251 | {},
252 | config, {
253 | output: {
254 | publicPath: `http://localhost:${port}${config.output.publicPath}`,
255 | },
256 | plugins: [
257 | new webpack.HotModuleReplacementPlugin(),
258 | new webpack.NamedModulesPlugin(),
259 | ],
260 | }
261 | );
262 |
263 | Object.keys(config.entry).forEach((key) => {
264 | resConfig.entry[key] = [
265 | 'react-hot-loader/patch',
266 | `webpack-dev-server/client?http://localhost:${port}`, // WebpackDevServer host and port
267 | 'webpack/hot/only-dev-server',
268 | ].concat(config.entry[key]);
269 | });
270 |
271 | return resConfig;
272 | };
273 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Boilerplate
2 |
3 | Example project. We are using it as a start point for out applications or as a base for an education of our developers.
4 |
5 | ## Commands
6 |
7 | | Command | Description |
8 | | - | - |
9 | | `yarn dev` | Run dev server |
10 | | `yarn production` | Run production server |
11 | | `yarn lint` | Check code with Eslint and Stylelint |
12 | | `yarn build` | Build production |
13 | | `yarn stats` | Run webpack statistic dashboard to analyze bundle size` |
14 | | `yarn locales:extract` | Extract locales from the app into `.pot` file |
15 | | `yarn storybook` | Run storybook |
16 | | `yarn test:acceptance` | Run acceptance tests |
17 |
18 | ## Frontend Architecture
19 |
20 | ### Project structure
21 |
22 | - `app` - our application files. React JS app is here
23 | - `.storybook` - storybook configuration files.
24 | - `bin` - CI automation scripts
25 | - `cypress` - acceptance tests
26 | - `docs` - docs related documents, assets
27 | - `public` - public static data, like favicon, robot.txt, that is not using in react js app and that doesn't have to go thought webpack process
28 | - `webpack` - webpack configuration parts
29 |
30 | #### Application structure
31 |
32 | Inside `app` folder:
33 |
34 | - `client` - client's entrypoint
35 | - `server` - server's entrypoint
36 | - `common` - code, that is shared between client and server
37 | - `common/components` - generic components. Core UI of our application. Elements, that can be publish and reused in other projects.
38 | - `common/containers/blocks` - not generic components. specific for this project. API driven and can't be re-used in other projects
39 | - `common/containers/forms` - application's forms
40 | - `common/containers/layouts` - layouts
41 | - `common/containers/pages` - pages
42 | - `common/helpers` - helpers.
43 | - `common/locales` - localization files and template
44 | - `common/redux` - redux modules. reducers and actions are here
45 | - `common/routes` - routes definitions.
46 | - `common/schemas` - normalizr schemas.
47 | - `common/services` - configuration of 3rd party modules and services
48 | - `common/store` - configuration of redux store
49 | - `common/styles` - shared styles, like variables
50 |
51 | ### Code style
52 |
53 | #### Eslint
54 |
55 | We're using eslint to keep js and jsx code style consistent. Check that your IDE is using Eslint file from this repo. Anyway, pre-commit hook is checking lint with our internal installed eslint version. So if your IDE is not showing you the errors, but you have them in pre-commit hook, re-check IDE :D
56 |
57 | ##### Atom
58 | Add plugin [linter-eslint](https://atom.io/packages/linter-eslint). Go to the plugin's configuration and enable option **Fix errors on save**
59 |
60 | 
61 |
62 | #### Stylelint
63 |
64 | Stylelint is using to control the codestyle in style files. Configure your IDE to use config from this repo.
65 |
66 | ### Git flow
67 |
68 | - **Stable branch** - `master`
69 | - Don't push directly to the stable branch. Use PRs instead
70 |
71 | **Workflow:**
72 |
73 | 1. Start a ticket with a new branch
74 | 2. Write code
75 | 3. Create Pull Request
76 | 4. Get an approve from one of your coworkers
77 | 5. Merge PR's branch with the stable branch
78 |
79 | #### Name of the branches
80 |
81 | We are not following some strict rule on this stage of branch naming. So we have a single rule for the branch names:
82 | 1. Make you branch names meaningful.
83 |
84 | Bad example
85 | ```
86 | fix-1
87 | redesign
88 | ```
89 |
90 | Good example
91 | ```
92 | fix-signals-table
93 | new-user-profile-page
94 | ```
95 |
96 | ##### JIRA tickets
97 |
98 | If you are using JIRA as a task manager, follow this naming convention
99 |
100 | ```
101 | [type of ticket]/[number of branch]-[short-title]
102 |
103 | feature/FRB-123-change-titles
104 | fix/FRB-431-retina-images
105 | ```
106 |
107 | ### Components
108 |
109 | We are creating React application with component-approach. This means, what we try to decompose our UI into re-usable parts.
110 |
111 | Try to make components PURE when it's possible. Avoid using redux or inner state for components in `app/common/components`.
112 |
113 | Base component contains next files:
114 |
115 | - `index.js` - base component
116 | - `styles.scss` - styles file
117 | - `index.story.js` - storybook file
118 |
119 | See the [example component](./app/common/components/_Component_).
120 |
121 | #### Recompose
122 |
123 | Use recompose to create logic layout of your components. recompose allow us to split logic into multiple re-usable parts.
124 |
125 | #### Storybook
126 |
127 | Storybook is using as a UI library. We are using it as documentation for our UI. The goals are:
128 |
129 | - help teammates to find the right component
130 | - help to understand how to use the component
131 | - avoid duplications
132 |
133 | **The rule is**: write a story for all generic pure components and blocks and show all the existing variations.
134 |
135 | Help your teammates understand from story how to use your component. Not just write the story of itself. Think about your colleagues.
136 |
137 | #### Styling
138 |
139 | Shortly about our styles:
140 | - CSS modules
141 | - PostCss with SCSS like style
142 |
143 | We're using scoped styles, so you don't need to use BEM or other methodology to avoid conflicts in the styles.
144 | In BEM terminology, you don't have to use elements. Use only block and modificators.
145 |
146 | **If you feel that you also need an element - think, probably you have to extract a new component from this template.**
147 |
148 | Bad example
149 |
150 | ```scss
151 | .page {
152 | &__title {}
153 | &__content {
154 | &_active {}
155 | }
156 | }
157 | ```
158 |
159 | Good example
160 |
161 | ```scss
162 | .root {} // root element
163 | .title {}
164 | .content {
165 | &.isActive {}
166 | }
167 | ```
168 |
169 | Use `is` prefix for the modificators.
170 |
171 | ##### Injecting styles to component
172 |
173 | We are using `isomorphic style loader` to calculate critical CSS path. Use withStyles HOC to connect component and style. Use `withStyles` alias for import.
174 |
175 | For example
176 |
177 | ```js
178 | import React from 'react';
179 | import PropTypes from 'prop-types';
180 | import withStyles from 'withStyles';
181 | import { compose } from 'recompose';
182 | import styles from './styles.scss';
183 |
184 | const Poster = ({ src, title }) => (
185 |
186 | );
187 |
188 | Poster.propTypes = {
189 | children: PropTypes.node,
190 | };
191 |
192 | export default compose(
193 | withStyles(styles)
194 | )(Poster);
195 | ```
196 |
197 | #### Prop names
198 |
199 | **Make names meaningful.**
200 |
201 | ```html
202 |
203 | ```
204 |
205 | Good example
206 | ```html
207 |
208 | ```
209 |
210 | **Make property for group of variants, not only of one specific case**
211 |
212 | Bad example
213 | ```html
214 |
215 |
216 |
217 | ```
218 |
219 | Good example
220 | ```html
221 |
222 |
223 |
224 | ```
225 |
226 | **Make property short**
227 |
228 | Bad example
229 |
230 | ```html
231 |
232 | ```
233 |
234 | Good example
235 |
236 | ```html
237 |
238 | ```
239 |
240 | **Use `on` prefix for callbacks**
241 |
242 | Bad example
243 | ```html
244 |
245 | ```
246 |
247 | Good example
248 | ```html
249 |
250 | ```
251 |
252 | **`true` if present**
253 |
254 | `` equals ``
255 |
256 | **`false` if missed**
257 |
258 | `` equals ``
259 |
260 |
261 | #### Usage
262 |
263 | Use component fully as a block. Don't make the components styles configurable outside. It has to have the deterministic number of possible variants.
264 |
265 | Bad example
266 |
267 | ```html
268 |
269 | ```
270 |
271 | This is a chore. Passing classname or style from parent component can solve a problem easily in short terms, but in the future, you will be updating your components and you will not remember about this modification. so you will not test it. and it would be a bug.
272 |
273 | Good example
274 |
275 | ```html
276 |
277 |
278 |
279 | ```
280 |
281 | ### Redux
282 |
283 | We are using redux as our global state manager in the app. Our base store structure is:
284 |
285 | - `data` - data redux modules.
286 | - `form` - connect redux-form
287 | - `routing` - connect react-router-redux
288 | - `ui` - reducers of UI components
289 |
290 | Actions and reducers are stored in the same file. We are using [`redux-actions`](npmjs.com/package/redux-actions) as a single way to create an action. See [example](./app/common/redux/ui/loading.js).
291 |
292 | #### Selectors
293 |
294 | Selectors are stored in [redux/index.js](./app/common/redux/index.js) file. Basic set of data selectors contains next methods:
295 |
296 | ```javascript
297 | export const getMovie = (state, id) => denormalize(id, schemas.movie, state.data);
298 |
299 | export const getMovies = (state, ids) => ids.map(id => getMovie(state, id));
300 |
301 | export const getAllMovies = state => getMovies(state, Object.keys(state.data.movies));
302 | ```
303 |
304 | ### Data
305 |
306 | #### API
307 |
308 | We are using [redux-api-middleware](npmjs.com/package/redux-api-middleware) to make the API calls. It's use universal fetch internally.
309 |
310 | Use method [`invoke`](./app/common/redux/api.js) to create API request actions.
311 |
312 | #### Caching, Normalization
313 |
314 | We use redux as a caching layer. Redux store contains data object, that has next structure
315 |
316 | example
317 | ```js
318 | data: {
319 | movies: {
320 | 1: {
321 | id: 1,
322 | name: 'The Shawshank Redemption'
323 | },
324 | 2: {
325 | id: 2,
326 | name: 'The Godfather'
327 | },
328 | },
329 | directors: {
330 | 1: {
331 | id: 1,
332 | name: 'Francis Ford Coppola'
333 | }
334 | }
335 | }
336 | ```
337 |
338 | So all the data entities are grouped by collection and is stored in map, there key is `id` of the entity and `value` is entity itself.
339 |
340 | This structure allows us to easily find entity in store by id.
341 |
342 | Each API request contains normalization of the response. [Example](./app/common/redux/data/movies.js).
343 |
344 | To normalize data we are using [`normalizr` package](npmjs.com/package/normalizr). Normalization schemas are store in [`schemas` folder](./app/common/schemas).
345 |
346 | See an example of fetching and normalization of the data [here](./app/common/redux/data/movies.js).
347 |
348 | #### Data usage on the pages
349 |
350 | Use `connect` and `selectors` to get the store data on the page. Don't store the data directly on the page. Store identifier and get the value by id, with selector instead.
351 |
352 | For example,
353 |
354 | ```js
355 | export default compose(
356 | withStyles(styles),
357 | translate(),
358 | withRouter,
359 | provideHooks({
360 | fetch: ({ dispatch, params, setProps }) => dispatch(fetchMovie(params.id)).then((response) => {
361 | setProps({
362 | movieId: response.payload.result,
363 | });
364 | }),
365 | }),
366 | connect((state, ownProps) => ({
367 | movie: getMovie(state, ownProps.movieId),
368 | })),
369 | )(MoviesDetailsPage);
370 |
371 | ```
372 | After the fetch of the data we don't store the whole object, but just the id of the movie and then we get whole object with getMovie selector. It allow us to be sure, that redux store is only one source of data in our applications. It helps to avoid many bugs.
373 |
374 | ### Routing
375 |
376 | 1. You have to be able to load page information based only on the URL params.
377 | 2. Use the same URL as a main API endpoint of the page.
378 |
379 | ```
380 | Page URL: /users
381 | API request: get:/users
382 |
383 | Page URL: /users/:id
384 | API request: get:/users/:id
385 |
386 | Page URL: /users/new
387 | API request: post:/users
388 | ```
389 |
390 | 3. If you need to save the state of the page - use the URL query params. `e.g /users?page=1, /users?q=John Doe`
391 | 4. Make URLs meaningful
392 |
393 |
394 | #### Fetching hooks
395 |
396 | Pages and layouts are 2 places, there data can be fetched. WE don't fetch the data into blocks. Only in the containers, that are used as a component in Routes, so they are URL driven.
397 |
398 | To fetch the data on the page we are using redial + react-router-redial
399 |
400 | | hook | beforeTransition | afterTransition | client | server |
401 | | - | - | - | - | - |
402 | | `fetch` | + | - | + | + |
403 | | `defer` | - | + | + | - |
404 | | `server` | - | + | - | + |
405 | | `done` | - | + | + | + |
406 |
407 | We're additional passing `dispatch` and `getState` methods to the hooks, so you can access the store and dispatch an action.
408 |
409 | ### Authorization
410 |
411 | TODO
412 |
413 | ### Testing
414 |
415 | #### Acceptance testing
416 |
417 | Cypress.io is used for E2E testing.
418 |
419 | To run test, execute next command. You need server started and installed Chrome browser on your machine.
420 |
421 | ```sh
422 | yarn test:acceptance
423 | ```
424 |
425 | Tests are storing in [cypress folder](./cypress).
426 |
427 | #### Unit testing
428 |
429 | TODO. add jest to boilerplate
430 |
431 | #### Visual regression testing
432 |
433 | TODO. add argos CI to boilerplate
434 |
435 | ### Localization
436 |
437 | We are using `gettext` standard for our basic localization process.
438 |
439 | #### Extract localization template
440 |
441 | ```bash
442 | yarn locales:extract
443 | ```
444 |
445 | This command will extract string for localization into `locales/source.pot` file.
446 |
447 | Use **Poedit** to create lang files based on .pot file.
448 |
449 | Localization is configured in `common/services/i18next.js`. Check the code to see how to import `.po` file with new language to the app.
450 |
451 | ### Forms
452 |
453 | We are using Redux-form as a single way to work with the forms.
454 |
455 | #### Validation
456 |
457 | We use per-form validation. For validation we are using [react-nebo15-validate](https://github.com/Nebo15/react-nebo15-validate/)
458 |
459 | [Example](./app/common/containers/forms/MovieForm/index.js)
460 |
461 | ##### Add custom validation message
462 |
463 | To add custom validation message, add it to ErrorMessages components. This component is used in Field components to show validation messages.
464 |
465 | See the [existing customization](./app/common/components/ErrorMessages/index.js). Use this common in all places, when you need to display the error message based on error object.
466 |
467 | ### 3rd party services
468 |
469 | Configuration of the 3rd party services is stored in [`common/services`](./app/common/services). See the example of i18next and validations configurations. They are separately connected to client and server entrypoints.
470 |
471 | ### SSR
472 |
473 | ### Node JS API
474 |
475 | ### Configuration
476 |
477 | ### Release & Deployment
478 |
479 | ### Build tools
480 |
481 | ## License
482 |
483 | See [LICENSE.md](LICENSE.md).
484 |
--------------------------------------------------------------------------------