├── .editorconfig
├── .eslintrc
├── .gitattributes
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .travis.yml
├── .vscode
└── settings.json
├── LICENSE.md
├── README.md
├── app
├── app.js
├── components
│ ├── Footer
│ │ ├── Footer.js
│ │ ├── index.js
│ │ ├── style.scss
│ │ └── tests
│ │ │ └── index.test.js
│ ├── Header
│ │ ├── Header.js
│ │ ├── images
│ │ │ └── banner.jpg
│ │ ├── index.js
│ │ ├── style.scss
│ │ └── tests
│ │ │ └── index.test.js
│ ├── Icons
│ │ ├── IssueIcon
│ │ │ ├── IssueIcon.js
│ │ │ ├── index.js
│ │ │ └── tests
│ │ │ │ └── index.test.js
│ │ └── index.js
│ ├── List
│ │ ├── List.js
│ │ ├── index.js
│ │ ├── style.scss
│ │ └── tests
│ │ │ └── index.test.js
│ ├── ListItem
│ │ ├── ListItem.js
│ │ ├── index.js
│ │ ├── style.scss
│ │ └── tests
│ │ │ └── index.test.js
│ ├── LoadingIndicator
│ │ ├── LoadingIndicator.js
│ │ ├── index.js
│ │ ├── style.scss
│ │ └── tests
│ │ │ └── index.test.js
│ └── ReposList
│ │ ├── ReposList.js
│ │ ├── index.js
│ │ └── tests
│ │ └── index.test.js
├── configureStore.js
├── containers
│ ├── App
│ │ ├── App.js
│ │ ├── actions.js
│ │ ├── constants.js
│ │ ├── index.js
│ │ ├── reducer.js
│ │ ├── selectors.js
│ │ ├── style.scss
│ │ └── tests
│ │ │ ├── actions.test.js
│ │ │ ├── index.test.js
│ │ │ ├── reducer.test.js
│ │ │ └── selectors.test.js
│ ├── FeaturePage
│ │ ├── FeaturePage.js
│ │ ├── Loadable.js
│ │ ├── index.js
│ │ ├── style.scss
│ │ └── tests
│ │ │ └── index.test.js
│ ├── HomePage
│ │ ├── HomePage.js
│ │ ├── Loadable.js
│ │ ├── actions.js
│ │ ├── constants.js
│ │ ├── index.js
│ │ ├── reducer.js
│ │ ├── saga.js
│ │ ├── selectors.js
│ │ ├── style.scss
│ │ └── tests
│ │ │ ├── __snapshots__
│ │ │ └── saga.test.js.snap
│ │ │ ├── actions.test.js
│ │ │ ├── index.test.js
│ │ │ ├── reducer.test.js
│ │ │ ├── saga.test.js
│ │ │ └── selectors.test.js
│ ├── NotFoundPage
│ │ ├── Loadable.js
│ │ ├── NotFoundPage.js
│ │ ├── index.js
│ │ ├── style.scss
│ │ └── tests
│ │ │ └── index.test.js
│ └── RepoListItem
│ │ ├── RepoListItem.js
│ │ ├── index.js
│ │ ├── style.scss
│ │ └── tests
│ │ └── index.test.js
├── images
│ └── favicon.ico
├── index.html
├── init.js
├── reducers.js
├── styles
│ ├── global-styles.scss
│ └── theme.scss
├── tests
│ └── store.test.js
└── utils
│ ├── checkStore.js
│ ├── constants.js
│ ├── history.js
│ ├── injectReducer.js
│ ├── injectSaga.js
│ ├── reducerInjectors.js
│ ├── request.js
│ ├── sagaInjectors.js
│ └── tests
│ ├── checkStore.test.js
│ ├── injectReducer.test.js
│ ├── injectSaga.test.js
│ ├── reducerInjectors.test.js
│ ├── request.test.js
│ └── sagaInjectors.test.js
├── babel.config.js
├── config
├── jest-mocks
│ ├── cssModule.js
│ └── image.js
├── jest.config.js
├── test-setup.js
├── webpack.base.babel.js
├── webpack.dev.babel.js
└── webpack.prod.babel.js
├── jest.config.js
├── package.json
├── server
├── index.js
├── middlewares
│ ├── addDevMiddlewares.js
│ ├── addProdMiddlewares.js
│ └── frontendMiddleware.js
└── util
│ ├── argv.js
│ ├── logger.js
│ └── port.js
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | insert_final_newline = true
9 | indent_style = space
10 | indent_size = 2
11 | trim_trailing_whitespace = true
12 |
13 | [*.md]
14 | trim_trailing_whitespace = false
15 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": ["airbnb", "plugin:react/recommended"],
4 | "env": {
5 | "browser": true,
6 | "node": true,
7 | "jest": true,
8 | "es6": true
9 | },
10 | "plugins": [
11 | "redux-saga",
12 | "react",
13 | "jsx-a11y"
14 | ],
15 | "parserOptions": {
16 | "ecmaVersion": 6,
17 | "sourceType": "module",
18 | "ecmaFeatures": {
19 | "jsx": true
20 | }
21 | },
22 | "rules": {
23 | "no-param-reassign": "off",
24 | "function-paren-newline": "off",
25 | "arrow-parens": [
26 | "error",
27 | "always"
28 | ],
29 | "arrow-body-style": [
30 | 2,
31 | "as-needed"
32 | ],
33 | "comma-dangle": [
34 | "error",
35 | "only-multiline"
36 | ],
37 | "import/no-extraneous-dependencies": 0,
38 | "import/prefer-default-export": 0,
39 | "indent": [
40 | 2,
41 | 2,
42 | {
43 | "SwitchCase": 1
44 | }
45 | ],
46 | "max-len": 0,
47 | "no-console": 1,
48 | "react/forbid-prop-types": 0,
49 | "react/jsx-curly-brace-presence": "off",
50 | "react/jsx-first-prop-new-line": [
51 | 2,
52 | "multiline"
53 | ],
54 | "react/jsx-filename-extension": 0,
55 | "react/require-default-props": 0,
56 | "react/self-closing-comp": 0,
57 | "redux-saga/no-yield-in-race": 2,
58 | "redux-saga/yield-effects": 2,
59 | "jsx-a11y/anchor-is-valid": 0,
60 | "react/jsx-one-expression-per-line": 0
61 | },
62 | "settings": {
63 | "import/resolver": {
64 | "webpack": {
65 | "config": "./config/webpack.prod.babel.js"
66 | }
67 | }
68 | }
69 | }
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # From https://github.com/Danimoth/gitattributes/blob/master/Web.gitattributes
2 |
3 | # Handle line endings automatically for files detected as text
4 | # and leave all files detected as binary untouched.
5 | * text=auto
6 |
7 | #
8 | # The above will handle all files NOT found below
9 | #
10 |
11 | #
12 | ## These files are text and should be normalized (Convert crlf => lf)
13 | #
14 |
15 | # source code
16 | *.php text
17 | *.css text
18 | *.sass text
19 | *.scss text
20 | *.less text
21 | *.styl text
22 | *.js text eol=lf
23 | *.coffee text
24 | *.json text
25 | *.htm text
26 | *.html text
27 | *.xml text
28 | *.svg text
29 | *.txt text
30 | *.ini text
31 | *.inc text
32 | *.pl text
33 | *.rb text
34 | *.py text
35 | *.scm text
36 | *.sql text
37 | *.sh text
38 | *.bat text
39 |
40 | # templates
41 | *.ejs text
42 | *.hbt text
43 | *.jade text
44 | *.haml text
45 | *.hbs text
46 | *.dot text
47 | *.tmpl text
48 | *.phtml text
49 |
50 | # server config
51 | .htaccess text
52 | .nginx.conf text
53 |
54 | # git config
55 | .gitattributes text
56 | .gitignore text
57 | .gitconfig text
58 |
59 | # code analysis config
60 | .jshintrc text
61 | .jscsrc text
62 | .jshintignore text
63 | .csslintrc text
64 |
65 | # misc config
66 | *.yaml text
67 | *.yml text
68 | .editorconfig text
69 |
70 | # build config
71 | *.npmignore text
72 | *.bowerrc text
73 |
74 | # Heroku
75 | Procfile text
76 | .slugignore text
77 |
78 | # Documentation
79 | *.md text
80 | LICENSE text
81 | AUTHORS text
82 |
83 |
84 | #
85 | ## These files are binary and should be left untouched
86 | #
87 |
88 | # (binary is a macro for -text -diff)
89 | *.png binary
90 | *.jpg binary
91 | *.jpeg binary
92 | *.gif binary
93 | *.ico binary
94 | *.mov binary
95 | *.mp4 binary
96 | *.mp3 binary
97 | *.flv binary
98 | *.fla binary
99 | *.swf binary
100 | *.gz binary
101 | *.zip binary
102 | *.7z binary
103 | *.ttf binary
104 | *.eot binary
105 | *.woff binary
106 | *.pyc binary
107 | *.pdf binary
108 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Don't check auto-generated stuff into git
2 | coverage
3 | build
4 | node_modules
5 | stats.json
6 |
7 | # Cruft
8 | .DS_Store
9 | npm-debug.log
10 | .idea
11 |
12 | # Logs
13 | yarn-error.log
14 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | build/
2 | node_modules/
3 | internals/generators/
4 | internals/scripts/
5 | package-lock.json
6 | yarn.lock
7 | package.json
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "trailingComma": "all"
8 | }
9 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | os: osx
4 |
5 | node_js:
6 | - 10
7 | - 8
8 |
9 | script:
10 | - npm run test
11 | - npm run build
12 |
13 | notifications:
14 | email:
15 | on_failure: change
16 |
17 | cache:
18 | yarn: true
19 | directories:
20 | - node_modules
21 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "workbench.colorCustomizations": {
3 | "tab.unfocusedActiveBorder": "#fff0"
4 | }
5 | }
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Dinesh Pandiyan
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # This project is no longer maitained. If you've been using the boilerplate, it will work well and good. Latest version of redux and react hooks patterns are not used in the boilerplate and it is not recommended to start a new project with this boilerplate in 2020.
2 |
3 |
4 |
5 |
6 |
7 |
A minimal, beginner friendly React-Redux boilerplate with all the industry best practices
8 |
9 |
10 |
11 |
29 |
30 |
31 |
32 |
35 |
36 |
37 | ## Why? [](http://www.ted.com/talks/simon_sinek_how_great_leaders_inspire_action)
38 |
39 | The whole React community knows and will unanimously agree that [react-boilerplate](https://github.com/react-boilerplate/react-boilerplate) is the ultimate starter template for kickstarting a React project. It's setup with all the industry best practices and standards. But it also has a lot more than what you just need to start a react-redux app. It took me quite some time to get my head around what was happening in the codebase and it's clearly not for starters. They quote this right in their readme,
40 |
41 | > Please note that this boilerplate is **production-ready and not meant for beginners**! If you're just starting out with react or redux, please refer to https://github.com/petehunt/react-howto instead. If you want a solid, battle-tested base to build your next product upon and have some experience with react, this is the perfect start for you.
42 |
43 | So it involves a lot of additional learning curve to get started with [react-boilerplate](https://github.com/react-boilerplate/react-boilerplate). That's why I forked it, stripped it down and made this _leaner, **beginner friendly**_ boilerplate without all the additional complexity.
44 |
45 |
46 | ## Features
47 |
48 | This boilerplate features all the latest tools and practices in the industry.
49 |
50 | - _React.js_ - **React 16**✨, React Router 5
51 | - _Redux.js_ - Redux saga and Reselect
52 | - _Babel_ - ES6, ESNext, Airbnb and React/Recommended config
53 | - _Webpack_ - **Webpack 4**✨, Hot Reloading, Code Splitting, Optimized Prod Build and more
54 | - _Test_ - Jest with Enzyme
55 | - _Lint_ - ESlint
56 | - _Styles_ - SCSS Styling
57 |
58 | Here are a few highlights to look out for in this boilerplate
59 |
60 |
61 | Instant feedback
62 | Enjoy the best DX (Developer eXperience) and code your app at the speed of thought! Your saved changes to the CSS and JS are reflected instantaneously without refreshing the page. Preserve application state even when you update something in the underlying code!
63 |
64 | Next generation JavaScript
65 | Use template strings, object destructuring, arrow functions, JSX syntax and more, today.
66 |
67 | Component Specific Styles
68 | Separate styles for each component. Style in the good old scss way but still keep it abstracted for each component.
69 |
70 | Industry-standard routing
71 | It's natural to want to add pages (e.g. `/about`) to your application, and routing makes this possible.
72 |
73 | Predictable state management
74 | Unidirectional data flow allows for change logging and time travel debugging.
75 |
76 | SEO
77 | We support SEO (document head tags management) for search engines that support indexing of JavaScript content. (eg. Google)
78 |
79 |
80 | But wait... there's more!
81 |
82 | - *The best test setup:* Automatically guarantee code quality and non-breaking
83 | changes. (Seen a react app with 99% test coverage before?)
84 | - *The fastest fonts:* Say goodbye to vacant text.
85 | - *Stay fast*: Profile your app's performance from the comfort of your command
86 | line!
87 | - *Catch problems:* TravisCI setup included by default, so your
88 | tests get run automatically on each code push.
89 |
90 |
91 | ## Quick start
92 |
93 | 1. Clone this repo using `git clone https://github.com/flexdinesh/react-redux-boilerplate.git`
94 | 2. Move to the appropriate directory: `cd react-redux-boilerplate`.
95 | 3. Run `yarn` or `npm install` to install dependencies.
96 | 4. Run `npm start` to see the example app at `http://localhost:3000`.
97 |
98 | Now you're ready build your beautiful React Application!
99 |
100 |
101 | ## Info
102 |
103 | These are the things I stripped out from [react-boilerplate](https://github.com/react-boilerplate/react-boilerplate) - _github project rules, ngrok tunneling, shjs, service worker, webpack dll plugin, i18n, styled-components, code generators and a few more._
104 |
105 |
106 | ## License
107 |
108 | MIT license, Copyright (c) 2018 Dinesh Pandiyan.
109 |
--------------------------------------------------------------------------------
/app/app.js:
--------------------------------------------------------------------------------
1 | /**
2 | * app.js
3 | *
4 | * This is the entry file for the application, only setup and boilerplate
5 | * code.
6 | */
7 |
8 | // Needed for redux-saga es6 generator support
9 | import '@babel/polyfill';
10 |
11 | // Import all the third party stuff
12 | import React from 'react';
13 | import ReactDOM from 'react-dom';
14 | import { Provider } from 'react-redux';
15 | import { ConnectedRouter } from 'connected-react-router';
16 | import history from 'utils/history';
17 | import 'sanitize.css/sanitize.css';
18 |
19 | // Import root app
20 | import App from 'containers/App';
21 |
22 | // Load the favicon
23 | /* eslint-disable import/no-webpack-loader-syntax */
24 | import '!file-loader?name=[name].[ext]!./images/favicon.ico';
25 | /* eslint-enable import/no-webpack-loader-syntax */
26 |
27 | // Import CSS reset and Global Styles
28 | import 'styles/theme.scss';
29 |
30 | import configureStore from './configureStore';
31 |
32 | // Import all initialization stuff
33 | import { registerOpenSans } from './init';
34 |
35 | registerOpenSans();
36 |
37 | // Create redux store with history
38 | const initialState = {};
39 | const store = configureStore(initialState, history);
40 | const MOUNT_NODE = document.getElementById('app');
41 |
42 | const render = () => {
43 | ReactDOM.render(
44 |
45 |
46 |
47 |
48 | ,
49 | MOUNT_NODE
50 | );
51 | };
52 |
53 | if (module.hot) {
54 | // Hot reloadable React components and translation json files
55 | // modules.hot.accept does not accept dynamic dependencies,
56 | // have to be constants at compile-time
57 | module.hot.accept(['containers/App'], () => {
58 | ReactDOM.unmountComponentAtNode(MOUNT_NODE);
59 | render();
60 | });
61 | }
62 |
63 | render();
64 |
--------------------------------------------------------------------------------
/app/components/Footer/Footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './style.scss';
3 |
4 | const Footer = () => (
5 |
6 | This project is licensed under the MIT license.
7 |
8 |
9 | );
10 |
11 | export default Footer;
12 |
--------------------------------------------------------------------------------
/app/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Footer';
2 |
--------------------------------------------------------------------------------
/app/components/Footer/style.scss:
--------------------------------------------------------------------------------
1 | footer {
2 | display: flex;
3 | justify-content: space-between;
4 | padding: 3em 0;
5 | border-top: 1px solid #666;
6 |
7 | a {
8 | color: #41addd;
9 |
10 | &:hover {
11 | color: #6cc0e5;
12 | }
13 | }
14 | }
--------------------------------------------------------------------------------
/app/components/Footer/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 |
4 | import Footer from '../index';
5 |
6 | describe('', () => {
7 | it('should render the copyright notice', () => {
8 | const renderedComponent = shallow();
9 | expect(
10 | renderedComponent.contains(
11 | This project is licensed under the MIT license.
12 | )
13 | ).toBe(true);
14 | });
15 |
16 | it('should render the credits', () => {
17 | const renderedComponent = shallow();
18 | expect(renderedComponent.text()).toContain('Dinesh Pandiyan');
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/app/components/Header/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import Banner from './images/banner.jpg';
4 | import './style.scss';
5 |
6 | class Header extends React.Component { // eslint-disable-line react/prefer-stateless-function
7 | render() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 | Home
16 |
17 |
18 | Features
19 |
20 |
21 |
22 | );
23 | }
24 | }
25 |
26 | export default Header;
27 |
--------------------------------------------------------------------------------
/app/components/Header/images/banner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flexdinesh/react-redux-boilerplate/735e196532fe5951bf10d08b5b6494934ecdcfc8/app/components/Header/images/banner.jpg
--------------------------------------------------------------------------------
/app/components/Header/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Header';
2 |
--------------------------------------------------------------------------------
/app/components/Header/style.scss:
--------------------------------------------------------------------------------
1 | .header {
2 | img {
3 | width: 100%;
4 | margin: 0 auto;
5 | display: block;
6 | }
7 |
8 | .nav-bar {
9 | text-align: center;
10 | }
11 |
12 | .router-link {
13 | display: inline-flex;
14 | padding: 0.25em 2em;
15 | margin: 1em;
16 | text-decoration: none;
17 | border-radius: 4px;
18 | -webkit-font-smoothing: antialiased;
19 | -webkit-touch-callout: none;
20 | user-select: none;
21 | cursor: pointer;
22 | outline: 0;
23 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
24 | font-weight: bold;
25 | font-size: 16px;
26 | border: 2px solid #41ADDD;
27 | color: #41ADDD;
28 |
29 | &:active {
30 | background: #41ADDD;
31 | color: #FFF;
32 | }
33 | }
34 | }
--------------------------------------------------------------------------------
/app/components/Header/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 |
4 | import Header from '../index';
5 |
6 | describe('', () => {
7 | it('should render a div', () => {
8 | const renderedComponent = shallow();
9 | expect(renderedComponent.length).toEqual(1);
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/app/components/Icons/IssueIcon/IssueIcon.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const IssueIcon = ({ className }) => (
5 |
6 |
7 |
8 | );
9 |
10 | IssueIcon.propTypes = {
11 | className: PropTypes.string
12 | };
13 |
14 | export default IssueIcon;
15 |
--------------------------------------------------------------------------------
/app/components/Icons/IssueIcon/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './IssueIcon';
2 |
--------------------------------------------------------------------------------
/app/components/Icons/IssueIcon/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 |
4 | import IssueIcon from '../index';
5 |
6 | describe(' ', () => {
7 | it('should render a SVG', () => {
8 | const renderedComponent = shallow( );
9 | expect(renderedComponent.find('svg').length).toBe(1);
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/app/components/Icons/index.js:
--------------------------------------------------------------------------------
1 | import IssueIcon from './IssueIcon';
2 |
3 | export { IssueIcon };
4 |
--------------------------------------------------------------------------------
/app/components/List/List.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import './style.scss';
4 |
5 | const List = ({ component, items }) => {
6 | const ComponentToRender = component;
7 | let content = (
);
8 |
9 | // If we have items, render them
10 | if (items) {
11 | content = items.map((item) => (
12 |
13 | ));
14 | } else {
15 | // Otherwise render a single component
16 | content = ( );
17 | }
18 |
19 | return (
20 |
25 | );
26 | };
27 |
28 | List.propTypes = {
29 | component: PropTypes.elementType.isRequired,
30 | items: PropTypes.array,
31 | };
32 |
33 | export default List;
34 |
--------------------------------------------------------------------------------
/app/components/List/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './List';
2 |
--------------------------------------------------------------------------------
/app/components/List/style.scss:
--------------------------------------------------------------------------------
1 | .list-wrapper {
2 | padding: 0;
3 | margin: 0;
4 | width: 100%;
5 | background-color: white;
6 | border: 1px solid #ccc;
7 | border-radius: 3px;
8 | overflow: hidden;
9 |
10 | ul {
11 | list-style: none;
12 | margin: 0;
13 | width: 100%;
14 | max-height: 30em;
15 | overflow-y: auto;
16 | padding: 0 1em;
17 | }
18 | }
--------------------------------------------------------------------------------
/app/components/List/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 |
4 | import ListItem from 'components/ListItem';
5 | import List from '../index';
6 |
7 | describe('
', () => {
8 | it('should render the component if no items are passed', () => {
9 | const renderedComponent = shallow(
);
10 | expect(renderedComponent.find(ListItem)).toBeDefined();
11 | });
12 |
13 | it('should pass all items props to rendered component', () => {
14 | const items = [{ id: 1, name: 'Hello' }, { id: 2, name: 'World' }];
15 |
16 | const component = ({ item }) => {item.name} ; // eslint-disable-line react/prop-types
17 |
18 | const renderedComponent = shallow(
19 |
20 | );
21 | expect(renderedComponent.find(component)).toHaveLength(2);
22 | expect(renderedComponent.find(component).at(0).prop('item')).toBe(items[0]);
23 | expect(renderedComponent.find(component).at(1).prop('item')).toBe(items[1]);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/app/components/ListItem/ListItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import './style.scss';
4 |
5 | const ListItem = ({ item }) => (
6 |
7 |
{item}
8 |
9 | );
10 |
11 | ListItem.propTypes = {
12 | item: PropTypes.any
13 | };
14 |
15 | export default ListItem;
16 |
--------------------------------------------------------------------------------
/app/components/ListItem/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './ListItem';
2 |
--------------------------------------------------------------------------------
/app/components/ListItem/style.scss:
--------------------------------------------------------------------------------
1 | .list-item-wrapper {
2 | width: 100%;
3 | height: 3em;
4 | display: flex;
5 | align-items: center;
6 | position: relative;
7 | border-top: 1px solid #eee;
8 |
9 | &:first-child {
10 | border-top: none;
11 | }
12 |
13 | .list-item {
14 | display: flex;
15 | justify-content: space-between;
16 | width: 100%;
17 | height: 100%;
18 | align-items: center;
19 | }
20 | }
--------------------------------------------------------------------------------
/app/components/ListItem/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { mount } from 'enzyme';
3 |
4 | import ListItem from '../index';
5 |
6 | describe(' ', () => {
7 | it('should have a className', () => {
8 | const renderedComponent = mount( );
9 | expect(renderedComponent.find('li').prop('className')).toBeDefined();
10 | });
11 |
12 | it('should render the content passed to it', () => {
13 | const content = Hello world!
;
14 | const renderedComponent = mount( );
15 | expect(renderedComponent.contains(content)).toBe(true);
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/app/components/LoadingIndicator/LoadingIndicator.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './style.scss';
3 |
4 | const LoadingIndicator = () => (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 |
17 | export default LoadingIndicator;
18 |
--------------------------------------------------------------------------------
/app/components/LoadingIndicator/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './LoadingIndicator';
2 |
--------------------------------------------------------------------------------
/app/components/LoadingIndicator/style.scss:
--------------------------------------------------------------------------------
1 | .loading-indicator {
2 | margin: 2em auto;
3 | position: relative;
4 | width: 64px;
5 | height: 64px;
6 | div {
7 | animation: lds-roller 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
8 | transform-origin: 32px 32px;
9 | &:after {
10 | content: " ";
11 | display: block;
12 | position: absolute;
13 | width: 6px;
14 | height: 6px;
15 | border-radius: 50%;
16 | background: grey;
17 | margin: -3px 0 0 -3px;
18 | }
19 | &:nth-child(1) {
20 | animation-delay: -0.036s;
21 | &:after {
22 | top: 50px;
23 | left: 50px;
24 | }
25 | }
26 | &:nth-child(2) {
27 | animation-delay: -0.072s;
28 | &:after {
29 | top: 54px;
30 | left: 45px;
31 | }
32 | }
33 | &:nth-child(3) {
34 | animation-delay: -0.108s;
35 | &:after {
36 | top: 57px;
37 | left: 39px;
38 | }
39 | }
40 | &:nth-child(4) {
41 | animation-delay: -0.144s;
42 | &:after {
43 | top: 58px;
44 | left: 32px;
45 | }
46 | }
47 | &:nth-child(5) {
48 | animation-delay: -0.18s;
49 | &:after {
50 | top: 57px;
51 | left: 25px;
52 | }
53 | }
54 | &:nth-child(6) {
55 | animation-delay: -0.216s;
56 | &:after {
57 | top: 54px;
58 | left: 19px;
59 | }
60 | }
61 | &:nth-child(7) {
62 | animation-delay: -0.252s;
63 | &:after {
64 | top: 50px;
65 | left: 14px;
66 | }
67 | }
68 | &:nth-child(8) {
69 | animation-delay: -0.288s;
70 | &:after {
71 | top: 45px;
72 | left: 10px;
73 | }
74 | }
75 | }
76 | }
77 |
78 | @keyframes lds-roller {
79 | 0% {
80 | transform: rotate(0deg);
81 | }
82 |
83 | 100% {
84 | transform: rotate(360deg);
85 | }
86 | }
--------------------------------------------------------------------------------
/app/components/LoadingIndicator/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'enzyme';
3 |
4 | import LoadingIndicator from '../index';
5 |
6 | describe(' ', () => {
7 | it('should render 13 divs', () => {
8 | const renderedComponent = render( );
9 | expect(renderedComponent.length).toBe(1);
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/app/components/ReposList/ReposList.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import List from 'components/List';
5 | import ListItem from 'components/ListItem';
6 | import LoadingIndicator from 'components/LoadingIndicator';
7 | import RepoListItem from 'containers/RepoListItem';
8 |
9 | const ReposList = ({ loading, error, repos }) => {
10 | if (loading) {
11 | return
;
12 | }
13 |
14 | if (error !== false) {
15 | const ErrorComponent = () => (
16 |
17 | );
18 | return
;
19 | }
20 |
21 | if (repos !== false) {
22 | return
;
23 | }
24 |
25 | return null;
26 | };
27 |
28 | ReposList.propTypes = {
29 | loading: PropTypes.bool,
30 | error: PropTypes.any,
31 | repos: PropTypes.any
32 | };
33 |
34 | export default ReposList;
35 |
--------------------------------------------------------------------------------
/app/components/ReposList/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './ReposList';
2 |
--------------------------------------------------------------------------------
/app/components/ReposList/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import { shallow, mount } from 'enzyme';
2 | import React from 'react';
3 |
4 | import RepoListItem from 'containers/RepoListItem';
5 | import List from 'components/List';
6 | import LoadingIndicator from 'components/LoadingIndicator';
7 | import ReposList from '../index';
8 |
9 | describe(' ', () => {
10 | it('should render the loading indicator when its loading', () => {
11 | const renderedComponent = shallow( );
12 | expect(
13 | renderedComponent.contains(
)
14 | ).toEqual(true);
15 | });
16 |
17 | it('should render an error if loading failed', () => {
18 | const renderedComponent = mount(
19 |
20 | );
21 | expect(renderedComponent.text()).toMatch(/Something went wrong/);
22 | });
23 |
24 | it('should render the repositories if loading was successful', () => {
25 | const repos = [
26 | {
27 | owner: {
28 | login: 'flexdinesh'
29 | },
30 | html_url: 'https://github.com/flexdinesh/react-redux-boilerplate',
31 | name: 'react-redux-boilerplate',
32 | open_issues_count: 20,
33 | full_name: 'flexdinesh/react-redux-boilerplate'
34 | }
35 | ];
36 | const renderedComponent = shallow(
37 |
38 | );
39 |
40 | expect(
41 | renderedComponent.contains(
42 |
43 | )
44 | ).toEqual(true);
45 | });
46 |
47 | it('should not render anything if nothing interesting is provided', () => {
48 | const renderedComponent = shallow(
49 |
50 | );
51 |
52 | expect(renderedComponent.html()).toEqual(null);
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/app/configureStore.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Create the store with dynamic reducers
3 | */
4 |
5 | import { createStore, applyMiddleware, compose } from 'redux';
6 | import { routerMiddleware } from 'connected-react-router';
7 | import createSagaMiddleware from 'redux-saga';
8 | import createReducer from './reducers';
9 |
10 | const sagaMiddleware = createSagaMiddleware();
11 |
12 | export default function configureStore(initialState = {}, history) {
13 | // Create the store with two middlewares
14 | // 1. sagaMiddleware: Makes redux-sagas work
15 | // 2. routerMiddleware: Syncs the location/URL path to the state
16 | const middlewares = [sagaMiddleware, routerMiddleware(history)];
17 |
18 | const enhancers = [applyMiddleware(...middlewares)];
19 |
20 | // If Redux DevTools Extension is installed use it, otherwise use Redux compose
21 | /* eslint-disable no-underscore-dangle */
22 | const composeEnhancers = process.env.NODE_ENV !== 'production' && typeof window === 'object' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
23 | ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
24 | // TODO Try to remove when `react-router-redux` is out of beta, LOCATION_CHANGE should not be fired more than once after hot reloading
25 | // Prevent recomputing reducers for `replaceReducer`
26 | shouldHotReload: false
27 | })
28 | : compose;
29 | /* eslint-enable */
30 |
31 | const store = createStore(createReducer(), initialState, composeEnhancers(...enhancers));
32 |
33 | // Extensions
34 | store.runSaga = sagaMiddleware.run;
35 | store.injectedReducers = {}; // Reducer registry
36 | store.injectedSagas = {}; // Saga registry
37 |
38 | /* istanbul ignore next */
39 | if (module.hot) {
40 | module.hot.accept('./reducers', () => {
41 | store.replaceReducer(createReducer(store.injectedReducers));
42 | store.dispatch({ type: '@@REDUCER_INJECTED' });
43 | });
44 | }
45 |
46 | return store;
47 | }
48 |
--------------------------------------------------------------------------------
/app/containers/App/App.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * App
4 | *
5 | * This component is the skeleton around the actual pages, and should only
6 | * contain code that should be seen on all pages. (e.g. navigation bar)
7 | */
8 |
9 | import React from 'react';
10 | import { Helmet } from 'react-helmet';
11 | import { Switch, Route } from 'react-router-dom';
12 |
13 | import HomePage from 'containers/HomePage/Loadable';
14 | import FeaturePage from 'containers/FeaturePage/Loadable';
15 | import NotFoundPage from 'containers/NotFoundPage/Loadable';
16 | import Header from 'components/Header';
17 | import Footer from 'components/Footer';
18 | import './style.scss';
19 |
20 | const App = () => (
21 |
22 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 |
38 | export default App;
39 |
--------------------------------------------------------------------------------
/app/containers/App/actions.js:
--------------------------------------------------------------------------------
1 | /*
2 | * App Actions
3 | *
4 | * Actions change things in your application
5 | * Since this boilerplate uses a uni-directional data flow, specifically redux,
6 | * we have these actions which are the only way your application interacts with
7 | * your application state. This guarantees that your state is up to date and nobody
8 | * messes it up weirdly somewhere.
9 | *
10 | * To add a new Action:
11 | * 1) Import your constant
12 | * 2) Add a function like this:
13 | * export function yourAction(var) {
14 | * return { type: YOUR_ACTION_CONSTANT, var: var }
15 | * }
16 | */
17 |
18 | import {
19 | LOAD_REPOS,
20 | LOAD_REPOS_SUCCESS,
21 | LOAD_REPOS_ERROR,
22 | } from './constants';
23 |
24 | /**
25 | * Load the repositories, this action starts the request saga
26 | *
27 | * @return {object} An action object with a type of LOAD_REPOS
28 | */
29 | export function loadRepos() {
30 | return {
31 | type: LOAD_REPOS,
32 | };
33 | }
34 |
35 | /**
36 | * Dispatched when the repositories are loaded by the request saga
37 | *
38 | * @param {array} repos The repository data
39 | * @param {string} username The current username
40 | *
41 | * @return {object} An action object with a type of LOAD_REPOS_SUCCESS passing the repos
42 | */
43 | export function reposLoaded(repos, username) {
44 | return {
45 | type: LOAD_REPOS_SUCCESS,
46 | repos,
47 | username,
48 | };
49 | }
50 |
51 | /**
52 | * Dispatched when loading the repositories fails
53 | *
54 | * @param {object} error The error
55 | *
56 | * @return {object} An action object with a type of LOAD_REPOS_ERROR passing the error
57 | */
58 | export function repoLoadingError(error) {
59 | return {
60 | type: LOAD_REPOS_ERROR,
61 | error,
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/app/containers/App/constants.js:
--------------------------------------------------------------------------------
1 | /*
2 | * AppConstants
3 | * Each action has a corresponding type, which the reducer knows and picks up on.
4 | * To avoid weird typos between the reducer and the actions, we save them as
5 | * constants here. We prefix them with 'yourproject/YourComponent' so we avoid
6 | * reducers accidentally picking up actions they shouldn't.
7 | *
8 | * Follow this format:
9 | * export const YOUR_ACTION_CONSTANT = 'yourproject/YourContainer/YOUR_ACTION_CONSTANT';
10 | */
11 |
12 | export const LOAD_REPOS = 'boilerplate/App/LOAD_REPOS';
13 | export const LOAD_REPOS_SUCCESS = 'boilerplate/App/LOAD_REPOS_SUCCESS';
14 | export const LOAD_REPOS_ERROR = 'boilerplate/App/LOAD_REPOS_ERROR';
15 | export const DEFAULT_LOCALE = 'en';
16 |
--------------------------------------------------------------------------------
/app/containers/App/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './App';
2 |
--------------------------------------------------------------------------------
/app/containers/App/reducer.js:
--------------------------------------------------------------------------------
1 | import { LOAD_REPOS_SUCCESS, LOAD_REPOS, LOAD_REPOS_ERROR } from './constants';
2 |
3 | // The initial state of the App
4 | export const initialState = {
5 | loading: false,
6 | error: false,
7 | currentUser: false,
8 | userData: {
9 | repositories: false,
10 | },
11 | };
12 |
13 | function appReducer(state = initialState, action) {
14 | switch (action.type) {
15 | case LOAD_REPOS: {
16 | const newState = {
17 | ...state,
18 | loading: true,
19 | error: false,
20 | userData: {
21 | repositories: false,
22 | },
23 | };
24 |
25 | return newState;
26 | }
27 | case LOAD_REPOS_SUCCESS: {
28 | const newState = {
29 | ...state,
30 | loading: false,
31 | userData: {
32 | repositories: action.repos,
33 | },
34 | currentUser: action.username,
35 | };
36 | return newState;
37 | }
38 |
39 | case LOAD_REPOS_ERROR: {
40 | return { ...state, error: action.error, loading: false };
41 | }
42 | default:
43 | return state;
44 | }
45 | }
46 |
47 | export default appReducer;
48 |
--------------------------------------------------------------------------------
/app/containers/App/selectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 | import { initialState } from './reducer';
3 |
4 | const selectGlobal = (state) => state.global || initialState;
5 |
6 | const selectRoute = (state) => state.router;
7 |
8 | const makeSelectCurrentUser = () => createSelector(
9 | selectGlobal,
10 | (globalState) => globalState.currentUser
11 | );
12 |
13 | const makeSelectLoading = () => createSelector(
14 | selectGlobal,
15 | (globalState) => globalState.loading
16 | );
17 |
18 | const makeSelectError = () => createSelector(
19 | selectGlobal,
20 | (globalState) => globalState.error
21 | );
22 |
23 | const makeSelectRepos = () => createSelector(
24 | selectGlobal,
25 | (globalState) => globalState.userData.repositories
26 | );
27 |
28 | const makeSelectLocation = () => createSelector(
29 | selectRoute,
30 | (routeState) => routeState.location
31 | );
32 |
33 | export {
34 | selectGlobal,
35 | makeSelectCurrentUser,
36 | makeSelectLoading,
37 | makeSelectError,
38 | makeSelectRepos,
39 | makeSelectLocation,
40 | };
41 |
--------------------------------------------------------------------------------
/app/containers/App/style.scss:
--------------------------------------------------------------------------------
1 | .app-wrapper {
2 | max-width: calc(768px + 16px * 2);
3 | margin: 0 auto;
4 | display: flex;
5 | min-height: 100%;
6 | padding: 0 16px;
7 | flex-direction: column;
8 | }
--------------------------------------------------------------------------------
/app/containers/App/tests/actions.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | LOAD_REPOS,
3 | LOAD_REPOS_SUCCESS,
4 | LOAD_REPOS_ERROR,
5 | } from '../constants';
6 |
7 | import {
8 | loadRepos,
9 | reposLoaded,
10 | repoLoadingError,
11 | } from '../actions';
12 |
13 | describe('App Actions', () => {
14 | describe('loadRepos', () => {
15 | it('should return the correct type', () => {
16 | const expectedResult = {
17 | type: LOAD_REPOS,
18 | };
19 |
20 | expect(loadRepos()).toEqual(expectedResult);
21 | });
22 | });
23 |
24 | describe('reposLoaded', () => {
25 | it('should return the correct type and the passed repos', () => {
26 | const fixture = ['Test'];
27 | const username = 'test';
28 | const expectedResult = {
29 | type: LOAD_REPOS_SUCCESS,
30 | repos: fixture,
31 | username,
32 | };
33 |
34 | expect(reposLoaded(fixture, username)).toEqual(expectedResult);
35 | });
36 | });
37 |
38 | describe('repoLoadingError', () => {
39 | it('should return the correct type and the error', () => {
40 | const fixture = {
41 | msg: 'Something went wrong!',
42 | };
43 | const expectedResult = {
44 | type: LOAD_REPOS_ERROR,
45 | error: fixture,
46 | };
47 |
48 | expect(repoLoadingError(fixture)).toEqual(expectedResult);
49 | });
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/app/containers/App/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 | import { Route } from 'react-router-dom';
4 |
5 | import Header from 'components/Header';
6 | import Footer from 'components/Footer';
7 | import App from '../index';
8 |
9 | describe(' ', () => {
10 | it('should render the header', () => {
11 | const renderedComponent = shallow( );
12 | expect(renderedComponent.find(Header).length).toBe(1);
13 | });
14 |
15 | it('should render some routes', () => {
16 | const renderedComponent = shallow( );
17 | expect(renderedComponent.find(Route).length).not.toBe(0);
18 | });
19 |
20 | it('should render the footer', () => {
21 | const renderedComponent = shallow( );
22 | expect(renderedComponent.find(Footer).length).toBe(1);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/app/containers/App/tests/reducer.test.js:
--------------------------------------------------------------------------------
1 | import appReducer from '../reducer';
2 | import { loadRepos, reposLoaded, repoLoadingError } from '../actions';
3 |
4 | describe('appReducer', () => {
5 | let state;
6 | beforeEach(() => {
7 | state = {
8 | loading: false,
9 | error: false,
10 | currentUser: false,
11 | userData: {
12 | repositories: false,
13 | },
14 | };
15 | });
16 |
17 | it('should return the initial state', () => {
18 | const expectedResult = state;
19 | expect(appReducer(undefined, {})).toEqual(expectedResult);
20 | });
21 |
22 | it('should handle the loadRepos action correctly', () => {
23 | const expectedResult = {
24 | ...state,
25 | loading: true,
26 | error: false,
27 | userData: { repositories: false },
28 | };
29 | expect(appReducer(state, loadRepos())).toEqual(expectedResult);
30 | });
31 |
32 | it('should handle the reposLoaded action correctly', () => {
33 | const fixture = [
34 | {
35 | name: 'My Repo',
36 | },
37 | ];
38 | const username = 'test';
39 | const expectedResult = {
40 | ...state,
41 | loading: false,
42 | currentUser: username,
43 | userData: { repositories: fixture },
44 | };
45 |
46 | expect(appReducer(state, reposLoaded(fixture, username))).toEqual(
47 | expectedResult,
48 | );
49 | });
50 |
51 | it('should handle the repoLoadingError action correctly', () => {
52 | const fixture = {
53 | msg: 'Not found',
54 | };
55 |
56 | const expectedResult = {
57 | ...state,
58 | error: fixture,
59 | loading: false,
60 | };
61 |
62 | expect(appReducer(state, repoLoadingError(fixture))).toEqual(
63 | expectedResult,
64 | );
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/app/containers/App/tests/selectors.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | selectGlobal,
3 | makeSelectCurrentUser,
4 | makeSelectLoading,
5 | makeSelectError,
6 | makeSelectRepos,
7 | makeSelectLocation,
8 | } from '../selectors';
9 |
10 | describe('selectGlobal', () => {
11 | it('should select the global state', () => {
12 | const globalState = {};
13 | const mockedState = {
14 | global: globalState,
15 | };
16 | expect(selectGlobal(mockedState)).toEqual(globalState);
17 | });
18 | });
19 |
20 | describe('makeSelectCurrentUser', () => {
21 | const currentUserSelector = makeSelectCurrentUser();
22 | it('should select the current user', () => {
23 | const username = 'flexdinesh';
24 | const mockedState = {
25 | global: {
26 | currentUser: username,
27 | },
28 | };
29 | expect(currentUserSelector(mockedState)).toEqual(username);
30 | });
31 | });
32 |
33 | describe('makeSelectLoading', () => {
34 | const loadingSelector = makeSelectLoading();
35 | it('should select the loading', () => {
36 | const loading = false;
37 | const mockedState = {
38 | global: {
39 | loading,
40 | },
41 | };
42 | expect(loadingSelector(mockedState)).toEqual(loading);
43 | });
44 | });
45 |
46 | describe('makeSelectError', () => {
47 | const errorSelector = makeSelectError();
48 | it('should select the error', () => {
49 | const error = 404;
50 | const mockedState = {
51 | global: {
52 | error,
53 | },
54 | };
55 | expect(errorSelector(mockedState)).toEqual(error);
56 | });
57 | });
58 |
59 | describe('makeSelectRepos', () => {
60 | const reposSelector = makeSelectRepos();
61 | it('should select the repos', () => {
62 | const repositories = [];
63 | const mockedState = {
64 | global: {
65 | userData: {
66 | repositories,
67 | },
68 | },
69 | };
70 | expect(reposSelector(mockedState)).toEqual(repositories);
71 | });
72 | });
73 |
74 | describe('makeSelectLocation', () => {
75 | const locationStateSelector = makeSelectLocation();
76 | it('should select the location', () => {
77 | const router = {
78 | location: { pathname: '/foo' },
79 | };
80 | const mockedState = {
81 | router,
82 | };
83 | expect(locationStateSelector(mockedState)).toEqual(router.location);
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/app/containers/FeaturePage/FeaturePage.js:
--------------------------------------------------------------------------------
1 | /*
2 | * FeaturePage
3 | *
4 | * List all the features
5 | */
6 | import React from 'react';
7 | import { Helmet } from 'react-helmet';
8 | import './style.scss';
9 |
10 | export default class FeaturePage extends React.Component {
11 | // eslint-disable-line react/prefer-stateless-function
12 |
13 | // Since state and props are static,
14 | // there's no need to re-render this component
15 | shouldComponentUpdate() {
16 | return false;
17 | }
18 |
19 | render() {
20 | return (
21 |
22 |
23 | Feature Page
24 |
28 |
29 |
Features
30 |
31 |
32 | Next generation JavaScript
33 |
34 | Use template strings, object destructuring, arrow functions, JSX
35 | syntax and more, today.
36 |
37 |
38 |
39 | Instant feedback
40 |
41 | Enjoy the best DX and code your app at the speed of thought! Your
42 | saved changes to the CSS and JS are reflected instantaneously
43 | without refreshing the page. Preserve application state even when
44 | you update something in the underlying code!
45 |
46 |
47 |
48 | Industry-standard routing
49 |
50 | {
51 | "Write composable CSS that's co-located with your components for complete modularity. Unique generated class names keep the specificity low while eliminating style clashes. Ship only the styles that are on the page for the best performance."
52 | }
53 |
54 |
55 |
56 | The Best Test Setup
57 |
58 | Automatically guarantee code quality and non-breaking changes.
59 | (Seen a react app with 99% test coverage before?)
60 |
61 |
62 |
63 |
and much more...
64 |
65 | );
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/app/containers/FeaturePage/Loadable.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Asynchronously loads the component for FeaturePage
3 | */
4 | import Loadable from 'react-loadable';
5 |
6 | import LoadingIndicator from 'components/LoadingIndicator';
7 |
8 | export default Loadable({
9 | loader: () => import('./index'),
10 | loading: LoadingIndicator,
11 | });
12 |
--------------------------------------------------------------------------------
/app/containers/FeaturePage/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './FeaturePage';
2 |
--------------------------------------------------------------------------------
/app/containers/FeaturePage/style.scss:
--------------------------------------------------------------------------------
1 | .feature-page {
2 | h1 {
3 | font-size: 2em;
4 | margin-bottom: 0.25em;
5 | }
6 |
7 | ul {
8 | font-family: Georgia, Times, 'Times New Roman', serif;
9 | padding-left: 1.75em;
10 |
11 | li {
12 | margin: 1em 0;
13 |
14 | p.title {
15 | font-weight: bold;
16 | }
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/app/containers/FeaturePage/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 |
4 | import FeaturePage from '../index';
5 |
6 | describe(' ', () => {
7 | it('should render its heading', () => {
8 | const renderedComponent = shallow( );
9 | expect(renderedComponent.contains(Features )).toBe(true);
10 | });
11 |
12 | it('should never re-render the component', () => {
13 | const renderedComponent = shallow( );
14 | const inst = renderedComponent.instance();
15 | expect(inst.shouldComponentUpdate()).toBe(false);
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/app/containers/HomePage/HomePage.js:
--------------------------------------------------------------------------------
1 | /*
2 | * HomePage
3 | *
4 | * This is the first thing users see of our App, at the '/' route
5 | */
6 |
7 | import React from 'react';
8 | import PropTypes from 'prop-types';
9 | import { Helmet } from 'react-helmet';
10 | import ReposList from 'components/ReposList';
11 | import './style.scss';
12 |
13 | export default class HomePage extends React.PureComponent { // eslint-disable-line react/prefer-stateless-function
14 | /**
15 | * when initial state username is not null, submit the form to load repos
16 | */
17 | componentDidMount() {
18 | const { username, onSubmitForm } = this.props;
19 | if (username && username.trim().length > 0) {
20 | onSubmitForm();
21 | }
22 | }
23 |
24 | render() {
25 | const {
26 | loading, error, repos, username, onChangeUsername, onSubmitForm
27 | } = this.props;
28 | const reposListProps = {
29 | loading,
30 | error,
31 | repos
32 | };
33 |
34 | return (
35 |
36 |
37 | Home Page
38 |
39 |
40 |
41 |
42 | Start your next react project in seconds
43 |
44 | A minimal React-Redux boilerplate with all the best practices
45 |
46 |
47 |
48 | Try me!
49 |
62 |
63 |
64 |
65 |
66 | );
67 | }
68 | }
69 |
70 | HomePage.propTypes = {
71 | loading: PropTypes.bool,
72 | error: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]),
73 | repos: PropTypes.oneOfType([PropTypes.array, PropTypes.bool]),
74 | onSubmitForm: PropTypes.func,
75 | username: PropTypes.string,
76 | onChangeUsername: PropTypes.func
77 | };
78 |
--------------------------------------------------------------------------------
/app/containers/HomePage/Loadable.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Asynchronously loads the component for HomePage
3 | */
4 | import Loadable from 'react-loadable';
5 |
6 | import LoadingIndicator from 'components/LoadingIndicator';
7 |
8 | export default Loadable({
9 | loader: () => import('./index'),
10 | loading: LoadingIndicator,
11 | });
12 |
--------------------------------------------------------------------------------
/app/containers/HomePage/actions.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Home Actions
3 | *
4 | * Actions change things in your application
5 | * Since this boilerplate uses a uni-directional data flow, specifically redux,
6 | * we have these actions which are the only way your application interacts with
7 | * your application state. This guarantees that your state is up to date and nobody
8 | * messes it up weirdly somewhere.
9 | *
10 | * To add a new Action:
11 | * 1) Import your constant
12 | * 2) Add a function like this:
13 | * export function yourAction(var) {
14 | * return { type: YOUR_ACTION_CONSTANT, var: var }
15 | * }
16 | */
17 |
18 | import { CHANGE_USERNAME } from './constants';
19 |
20 | /**
21 | * Changes the input field of the form
22 | *
23 | * @param {name} name The new text of the input field
24 | *
25 | * @return {object} An action object with a type of CHANGE_USERNAME
26 | */
27 | export function changeUsername(name) {
28 | return {
29 | type: CHANGE_USERNAME,
30 | name
31 | };
32 | }
33 |
--------------------------------------------------------------------------------
/app/containers/HomePage/constants.js:
--------------------------------------------------------------------------------
1 | /*
2 | * HomeConstants
3 | * Each action has a corresponding type, which the reducer knows and picks up on.
4 | * To avoid weird typos between the reducer and the actions, we save them as
5 | * constants here. We prefix them with 'yourproject/YourComponent' so we avoid
6 | * reducers accidentally picking up actions they shouldn't.
7 | *
8 | * Follow this format:
9 | * export const YOUR_ACTION_CONSTANT = 'yourproject/YourContainer/YOUR_ACTION_CONSTANT';
10 | */
11 |
12 | export const CHANGE_USERNAME = 'boilerplate/Home/CHANGE_USERNAME';
13 |
--------------------------------------------------------------------------------
/app/containers/HomePage/index.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { compose } from 'redux';
3 | import { createStructuredSelector } from 'reselect';
4 | import injectReducer from 'utils/injectReducer';
5 | import injectSaga from 'utils/injectSaga';
6 | import {
7 | makeSelectRepos,
8 | makeSelectLoading,
9 | makeSelectError
10 | } from 'containers/App/selectors';
11 | import { loadRepos } from '../App/actions';
12 | import { changeUsername } from './actions';
13 | import { makeSelectUsername } from './selectors';
14 | import reducer from './reducer';
15 | import saga from './saga';
16 | import HomePage from './HomePage';
17 |
18 | const mapDispatchToProps = (dispatch) => ({
19 | onChangeUsername: (evt) => dispatch(changeUsername(evt.target.value)),
20 | onSubmitForm: (evt) => {
21 | if (evt !== undefined && evt.preventDefault) evt.preventDefault();
22 | dispatch(loadRepos());
23 | }
24 | });
25 |
26 | const mapStateToProps = createStructuredSelector({
27 | repos: makeSelectRepos(),
28 | username: makeSelectUsername(),
29 | loading: makeSelectLoading(),
30 | error: makeSelectError()
31 | });
32 |
33 | const withConnect = connect(mapStateToProps, mapDispatchToProps);
34 |
35 | const withReducer = injectReducer({ key: 'home', reducer });
36 | const withSaga = injectSaga({ key: 'home', saga });
37 |
38 | export default compose(withReducer, withSaga, withConnect)(HomePage);
39 | export { mapDispatchToProps };
40 |
--------------------------------------------------------------------------------
/app/containers/HomePage/reducer.js:
--------------------------------------------------------------------------------
1 | import { CHANGE_USERNAME } from './constants';
2 |
3 | // The initial state of the App
4 | const initialState = {
5 | username: '',
6 | };
7 |
8 | function homeReducer(state = initialState, action) {
9 | switch (action.type) {
10 | case CHANGE_USERNAME:
11 | // Delete prefixed '@' from the github username
12 | return { ...state, username: action.name.replace(/@/gi, '') };
13 | default:
14 | return state;
15 | }
16 | }
17 |
18 | export default homeReducer;
19 |
--------------------------------------------------------------------------------
/app/containers/HomePage/saga.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Gets the repositories of the user from Github
3 | */
4 |
5 | import {
6 | call, put, select, takeLatest
7 | } from 'redux-saga/effects';
8 | import { LOAD_REPOS } from 'containers/App/constants';
9 | import { reposLoaded, repoLoadingError } from 'containers/App/actions';
10 |
11 | import request from 'utils/request';
12 | import { makeSelectUsername } from 'containers/HomePage/selectors';
13 |
14 | /**
15 | * Github repos request/response handler
16 | */
17 | export function* getRepos() {
18 | // Select username from store
19 | const username = yield select(makeSelectUsername());
20 | const requestURL = `https://api.github.com/users/${username}/repos?type=all&sort=updated`;
21 |
22 | try {
23 | // Call our request helper (see 'utils/request')
24 | const repos = yield call(request, requestURL);
25 | yield put(reposLoaded(repos, username));
26 | } catch (err) {
27 | yield put(repoLoadingError(err));
28 | }
29 | }
30 |
31 | /**
32 | * Root saga manages watcher lifecycle
33 | */
34 | export default function* githubData() {
35 | // Watches for LOAD_REPOS actions and calls getRepos when one comes in.
36 | // By using `takeLatest` only the result of the latest API call is applied.
37 | // It returns task descriptor (just like fork) so we can continue execution
38 | // It will be cancelled automatically on component unmount
39 | yield takeLatest(LOAD_REPOS, getRepos);
40 | }
41 |
--------------------------------------------------------------------------------
/app/containers/HomePage/selectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 |
3 | const selectHome = (state) => state.home;
4 |
5 | const makeSelectUsername = () => createSelector(
6 | selectHome,
7 | (homeState) => homeState.username
8 | );
9 |
10 | export {
11 | selectHome,
12 | makeSelectUsername,
13 | };
14 |
--------------------------------------------------------------------------------
/app/containers/HomePage/style.scss:
--------------------------------------------------------------------------------
1 | .home-page {
2 |
3 | h2 {
4 | font-size: 1.5em;
5 | }
6 |
7 | section {
8 | margin: 3em auto;
9 |
10 | &:first-child {
11 | margin-top: 0;
12 | }
13 |
14 | &.centered {
15 | text-align: center;
16 | }
17 | }
18 |
19 | form {
20 | margin-bottom: 1em;
21 |
22 | input {
23 | outline: none;
24 | border-bottom: 1px dotted #999;
25 | }
26 | }
27 |
28 | span.at-prefix {
29 | color: black;
30 | margin-left: 0.4em;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/containers/HomePage/tests/__snapshots__/saga.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`getRepos Saga should call the repoLoadingError action if the response errors 1`] = `
4 | Object {
5 | "@@redux-saga/IO": true,
6 | "combinator": false,
7 | "payload": Object {
8 | "args": Array [],
9 | "selector": [Function],
10 | },
11 | "type": "SELECT",
12 | }
13 | `;
14 |
15 | exports[`getRepos Saga should call the repoLoadingError action if the response errors 2`] = `
16 | Object {
17 | "@@redux-saga/IO": true,
18 | "combinator": false,
19 | "payload": Object {
20 | "args": Array [
21 | "https://api.github.com/users/flexdinesh/repos?type=all&sort=updated",
22 | ],
23 | "context": null,
24 | "fn": [Function],
25 | },
26 | "type": "CALL",
27 | }
28 | `;
29 |
30 | exports[`getRepos Saga should dispatch the reposLoaded action if it requests the data successfully 1`] = `
31 | Object {
32 | "@@redux-saga/IO": true,
33 | "combinator": false,
34 | "payload": Object {
35 | "args": Array [],
36 | "selector": [Function],
37 | },
38 | "type": "SELECT",
39 | }
40 | `;
41 |
42 | exports[`getRepos Saga should dispatch the reposLoaded action if it requests the data successfully 2`] = `
43 | Object {
44 | "@@redux-saga/IO": true,
45 | "combinator": false,
46 | "payload": Object {
47 | "args": Array [
48 | "https://api.github.com/users/flexdinesh/repos?type=all&sort=updated",
49 | ],
50 | "context": null,
51 | "fn": [Function],
52 | },
53 | "type": "CALL",
54 | }
55 | `;
56 |
--------------------------------------------------------------------------------
/app/containers/HomePage/tests/actions.test.js:
--------------------------------------------------------------------------------
1 | import { CHANGE_USERNAME } from '../constants';
2 |
3 | import { changeUsername } from '../actions';
4 |
5 | describe('Home Actions', () => {
6 | describe('changeUsername', () => {
7 | it('should return the correct type and the passed name', () => {
8 | const fixture = 'Max';
9 | const expectedResult = {
10 | type: CHANGE_USERNAME,
11 | name: fixture
12 | };
13 |
14 | expect(changeUsername(fixture)).toEqual(expectedResult);
15 | });
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/app/containers/HomePage/tests/index.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Test the HomePage
3 | */
4 |
5 | import React from 'react';
6 | import { shallow, mount } from 'enzyme';
7 |
8 | import ReposList from 'components/ReposList';
9 | import HomePage from '../HomePage';
10 | import { mapDispatchToProps } from '../index';
11 | import { changeUsername } from '../actions';
12 | import { loadRepos } from '../../App/actions';
13 |
14 | describe(' ', () => {
15 | it('should render the repos list', () => {
16 | const renderedComponent = shallow(
17 |
18 | );
19 | expect(
20 | renderedComponent.contains( )
21 | ).toEqual(true);
22 | });
23 |
24 | it('should render fetch the repos on mount if a username exists', () => {
25 | const submitSpy = jest.fn();
26 | mount(
27 | {}}
30 | onSubmitForm={submitSpy}
31 | />
32 | );
33 | expect(submitSpy).toHaveBeenCalled();
34 | });
35 |
36 | it('should not call onSubmitForm if username is an empty string', () => {
37 | const submitSpy = jest.fn();
38 | mount( {}} onSubmitForm={submitSpy} />);
39 | expect(submitSpy).not.toHaveBeenCalled();
40 | });
41 |
42 | it('should not call onSubmitForm if username is null', () => {
43 | const submitSpy = jest.fn();
44 | mount(
45 | {}}
48 | onSubmitForm={submitSpy}
49 | />
50 | );
51 | expect(submitSpy).not.toHaveBeenCalled();
52 | });
53 |
54 | describe('mapDispatchToProps', () => {
55 | describe('onChangeUsername', () => {
56 | it('should be injected', () => {
57 | const dispatch = jest.fn();
58 | const result = mapDispatchToProps(dispatch);
59 | expect(result.onChangeUsername).toBeDefined();
60 | });
61 |
62 | it('should dispatch changeUsername when called', () => {
63 | const dispatch = jest.fn();
64 | const result = mapDispatchToProps(dispatch);
65 | const username = 'flexdinesh';
66 | result.onChangeUsername({ target: { value: username } });
67 | expect(dispatch).toHaveBeenCalledWith(changeUsername(username));
68 | });
69 | });
70 |
71 | describe('onSubmitForm', () => {
72 | it('should be injected', () => {
73 | const dispatch = jest.fn();
74 | const result = mapDispatchToProps(dispatch);
75 | expect(result.onSubmitForm).toBeDefined();
76 | });
77 |
78 | it('should dispatch loadRepos when called', () => {
79 | const dispatch = jest.fn();
80 | const result = mapDispatchToProps(dispatch);
81 | result.onSubmitForm();
82 | expect(dispatch).toHaveBeenCalledWith(loadRepos());
83 | });
84 |
85 | it('should preventDefault if called with event', () => {
86 | const preventDefault = jest.fn();
87 | const result = mapDispatchToProps(() => {});
88 | const evt = { preventDefault };
89 | result.onSubmitForm(evt);
90 | expect(preventDefault).toHaveBeenCalledWith();
91 | });
92 | });
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/app/containers/HomePage/tests/reducer.test.js:
--------------------------------------------------------------------------------
1 | import homeReducer from '../reducer';
2 | import { changeUsername } from '../actions';
3 |
4 | describe('homeReducer', () => {
5 | let state;
6 | beforeEach(() => {
7 | state = {
8 | username: '',
9 | };
10 | });
11 |
12 | it('should return the initial state', () => {
13 | const expectedResult = state;
14 | expect(homeReducer(undefined, {})).toEqual(expectedResult);
15 | });
16 |
17 | it('should handle the changeUsername action correctly', () => {
18 | const fixture = 'flexdinesh';
19 | const expectedResult = { ...state, username: fixture };
20 |
21 | expect(homeReducer(state, changeUsername(fixture))).toEqual(expectedResult);
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/app/containers/HomePage/tests/saga.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Tests for HomePage sagas
3 | */
4 |
5 | import { put, takeLatest } from 'redux-saga/effects';
6 |
7 | import { LOAD_REPOS } from 'containers/App/constants';
8 | import { reposLoaded, repoLoadingError } from 'containers/App/actions';
9 |
10 | import githubData, { getRepos } from '../saga';
11 |
12 | const username = 'flexdinesh';
13 |
14 | /* eslint-disable redux-saga/yield-effects */
15 | describe('getRepos Saga', () => {
16 | let getReposGenerator;
17 |
18 | // We have to test twice, once for a successful load and once for an unsuccessful one
19 | // so we do all the stuff that happens beforehand automatically in the beforeEach
20 | beforeEach(() => {
21 | getReposGenerator = getRepos();
22 |
23 | const selectDescriptor = getReposGenerator.next().value;
24 | expect(selectDescriptor).toMatchSnapshot();
25 |
26 | const callDescriptor = getReposGenerator.next(username).value;
27 | expect(callDescriptor).toMatchSnapshot();
28 | });
29 |
30 | it('should dispatch the reposLoaded action if it requests the data successfully', () => {
31 | const response = [{
32 | name: 'First repo',
33 | }, {
34 | name: 'Second repo',
35 | }];
36 | const putDescriptor = getReposGenerator.next(response).value;
37 | expect(putDescriptor).toEqual(put(reposLoaded(response, username)));
38 | });
39 |
40 | it('should call the repoLoadingError action if the response errors', () => {
41 | const response = new Error('Some error');
42 | const putDescriptor = getReposGenerator.throw(response).value;
43 | expect(putDescriptor).toEqual(put(repoLoadingError(response)));
44 | });
45 | });
46 |
47 | describe('githubDataSaga Saga', () => {
48 | const githubDataSaga = githubData();
49 |
50 | it('should start task to watch for LOAD_REPOS action', () => {
51 | const takeLatestDescriptor = githubDataSaga.next().value;
52 | expect(takeLatestDescriptor).toEqual(takeLatest(LOAD_REPOS, getRepos));
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/app/containers/HomePage/tests/selectors.test.js:
--------------------------------------------------------------------------------
1 | import { selectHome, makeSelectUsername } from '../selectors';
2 |
3 | describe('selectHome', () => {
4 | it('should select the home state', () => {
5 | const homeState = {
6 | userData: {},
7 | };
8 | const mockedState = {
9 | home: homeState,
10 | };
11 | expect(selectHome(mockedState)).toEqual(homeState);
12 | });
13 | });
14 |
15 | describe('makeSelectUsername', () => {
16 | const usernameSelector = makeSelectUsername();
17 | it('should select the username', () => {
18 | const username = 'flexdinesh';
19 | const mockedState = {
20 | home: {
21 | username,
22 | },
23 | };
24 | expect(usernameSelector(mockedState)).toEqual(username);
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/app/containers/NotFoundPage/Loadable.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Asynchronously loads the component for NotFoundPage
3 | */
4 | import Loadable from 'react-loadable';
5 |
6 | import LoadingIndicator from 'components/LoadingIndicator';
7 |
8 | export default Loadable({
9 | loader: () => import('./index'),
10 | loading: LoadingIndicator,
11 | });
12 |
--------------------------------------------------------------------------------
/app/containers/NotFoundPage/NotFoundPage.js:
--------------------------------------------------------------------------------
1 | /**
2 | * NotFoundPage
3 | *
4 | * This is the page we show when the user visits a url that doesn't have a route
5 | */
6 |
7 | import React from 'react';
8 | import './style.scss';
9 |
10 | export default function NotFound() {
11 | return (
12 |
13 | Page not found.
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/app/containers/NotFoundPage/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './NotFoundPage';
2 |
--------------------------------------------------------------------------------
/app/containers/NotFoundPage/style.scss:
--------------------------------------------------------------------------------
1 | .not-found-page {
2 | h1 {
3 | font-size: 2em;
4 | margin-bottom: 0.25em;
5 | }
6 | }
--------------------------------------------------------------------------------
/app/containers/NotFoundPage/tests/index.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Testing the NotFoundPage
3 | */
4 |
5 | import React from 'react';
6 | import { shallow } from 'enzyme';
7 |
8 | import NotFound from '../index';
9 |
10 | describe(' ', () => {
11 | it('should render the Page Not Found text', () => {
12 | const renderedComponent = shallow( );
13 | expect(renderedComponent.contains(Page not found. )).toEqual(true);
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/app/containers/RepoListItem/RepoListItem.js:
--------------------------------------------------------------------------------
1 | /**
2 | * RepoListItem
3 | *
4 | * Lists the name and the issue count of a repository
5 | */
6 |
7 | import React from 'react';
8 | import PropTypes from 'prop-types';
9 | import ListItem from 'components/ListItem';
10 | import { IssueIcon } from 'components/Icons';
11 | import './style.scss';
12 |
13 | export default class RepoListItem extends React.PureComponent { // eslint-disable-line react/prefer-stateless-function
14 | render() {
15 | const { item, currentUser } = this.props;
16 | let nameprefix = '';
17 |
18 | // If the repository is owned by a different person than we got the data for
19 | // it's a fork and we should show the name of the owner
20 | if (item.owner.login !== currentUser) {
21 | nameprefix = `${item.owner.login}/`;
22 | }
23 |
24 | // Put together the content of the repository
25 | const content = (
26 |
35 | );
36 |
37 | // Render the content into a list item
38 | return (
39 |
40 | );
41 | }
42 | }
43 |
44 | RepoListItem.propTypes = {
45 | item: PropTypes.object,
46 | currentUser: PropTypes.string,
47 | };
48 |
--------------------------------------------------------------------------------
/app/containers/RepoListItem/index.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { createStructuredSelector } from 'reselect';
3 | import { makeSelectCurrentUser } from 'containers/App/selectors';
4 | import RepoListItem from './RepoListItem';
5 |
6 | export default connect(
7 | createStructuredSelector({
8 | currentUser: makeSelectCurrentUser()
9 | })
10 | )(RepoListItem);
11 |
--------------------------------------------------------------------------------
/app/containers/RepoListItem/style.scss:
--------------------------------------------------------------------------------
1 | .repo-list-item {
2 | width: 100%;
3 | height: 100%;
4 | display: flex;
5 | align-items: space-between;
6 |
7 | &__issue-link {
8 | height: 100%;
9 | color: black;
10 | display: flex;
11 | align-items: center;
12 | justify-content: center;
13 | }
14 |
15 | &__repo-link {
16 | height: 100%;
17 | color: black;
18 | display: flex;
19 | align-items: center;
20 | width: 100%;
21 | }
22 |
23 | &__issue-icon {
24 | fill: #ccc;
25 | margin-right: 0.25em;
26 | }
27 | }
--------------------------------------------------------------------------------
/app/containers/RepoListItem/tests/index.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Test the repo list item
3 | */
4 |
5 | import React from 'react';
6 | import { shallow, render } from 'enzyme';
7 |
8 | import ListItem from 'components/ListItem';
9 | import RepoListItem from '../RepoListItem';
10 |
11 | const renderComponent = (props = {}) => render( );
12 |
13 | describe.only(' ', () => {
14 | let item;
15 |
16 | // Before each test reset the item data for safety
17 | beforeEach(() => {
18 | item = {
19 | owner: {
20 | login: 'flexdinesh'
21 | },
22 | html_url: 'https://github.com/flexdinesh/react-redux-boilerplate',
23 | name: 'react-redux-boilerplate',
24 | open_issues_count: 20,
25 | full_name: 'flexdinesh/react-redux-boilerplate'
26 | };
27 | });
28 |
29 | it('should render a ListItem', () => {
30 | const renderedComponent = shallow( );
31 | expect(renderedComponent.find(ListItem).length).toBe(1);
32 | });
33 |
34 | it('should not render the current username', () => {
35 | const renderedComponent = renderComponent({
36 | item,
37 | currentUser: item.owner.login
38 | });
39 | expect(renderedComponent.text()).not.toContain(item.owner.login);
40 | });
41 |
42 | it('should render usernames that are not the current one', () => {
43 | const renderedComponent = renderComponent({
44 | item,
45 | currentUser: 'nikgraf'
46 | });
47 | expect(renderedComponent.text()).toContain(item.owner.login);
48 | });
49 |
50 | it('should render the repo name', () => {
51 | const renderedComponent = renderComponent({ item });
52 | expect(renderedComponent.text()).toContain(item.name);
53 | });
54 |
55 | it('should render the issue count', () => {
56 | const renderedComponent = renderComponent({ item });
57 | expect(renderedComponent.text()).toContain(item.open_issues_count);
58 | });
59 |
60 | it('should render the IssueIcon', () => {
61 | const renderedComponent = renderComponent({ item });
62 | expect(renderedComponent.find('svg').length).toBe(1);
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/app/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/flexdinesh/react-redux-boilerplate/735e196532fe5951bf10d08b5b6494934ecdcfc8/app/images/favicon.ico
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | React-Redux Boilerplate
12 |
13 |
14 |
15 | If you're seeing this message, that means JavaScript has been disabled on your browser , please enable JS to make this app work.
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/app/init.js:
--------------------------------------------------------------------------------
1 | import FontFaceObserver from 'fontfaceobserver';
2 |
3 | /* istanbul ignore next */
4 | // Observe loading of Open Sans (to remove open sans, remove the tag in
5 | // the index.html file and this observer)
6 | const openSansObserver = new FontFaceObserver('Open Sans', {});
7 |
8 | /* istanbul ignore next */
9 | // When Open Sans is loaded, add a font-family using Open Sans to the body
10 | const registerOpenSans = () => openSansObserver.load().then(() => {
11 | document.body.classList.add('fontLoaded');
12 | }, () => {
13 | document.body.classList.remove('fontLoaded');
14 | });
15 |
16 | export { registerOpenSans };
17 |
--------------------------------------------------------------------------------
/app/reducers.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Combine all reducers in this file and export the combined reducers.
3 | */
4 |
5 | import { combineReducers } from 'redux';
6 | import { connectRouter } from 'connected-react-router';
7 |
8 | import history from 'utils/history';
9 | import globalReducer from 'containers/App/reducer';
10 |
11 | /**
12 | * Merges the main reducer with the router state and dynamically injected reducers
13 | */
14 | export default function createReducer(injectedReducers = {}) {
15 | const rootReducer = combineReducers({
16 | global: globalReducer,
17 | router: connectRouter(history),
18 | ...injectedReducers,
19 | });
20 |
21 | return rootReducer;
22 | }
23 |
--------------------------------------------------------------------------------
/app/styles/global-styles.scss:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | height: 100%;
4 | width: 100%;
5 | }
6 |
7 | body {
8 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
9 | }
10 |
11 | body.fontLoaded {
12 | font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
13 | }
14 |
15 | #app {
16 | background-color: #fafafa;
17 | min-height: 100%;
18 | min-width: 100%;
19 | }
20 |
21 | p,
22 | label {
23 | font-family: Georgia, Times, 'Times New Roman', serif;
24 | line-height: 1.5em;
25 | }
--------------------------------------------------------------------------------
/app/styles/theme.scss:
--------------------------------------------------------------------------------
1 | @import './global-styles.scss';
2 |
--------------------------------------------------------------------------------
/app/tests/store.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Test store addons
3 | */
4 |
5 | import { browserHistory } from 'react-router-dom';
6 | import configureStore from '../configureStore';
7 |
8 | describe('configureStore', () => {
9 | let store;
10 |
11 | beforeAll(() => {
12 | store = configureStore({}, browserHistory);
13 | });
14 |
15 | describe('injectedReducers', () => {
16 | it('should contain an object for reducers', () => {
17 | expect(typeof store.injectedReducers).toBe('object');
18 | });
19 | });
20 |
21 | describe('injectedSagas', () => {
22 | it('should contain an object for sagas', () => {
23 | expect(typeof store.injectedSagas).toBe('object');
24 | });
25 | });
26 |
27 | describe('runSaga', () => {
28 | it('should contain a hook for `sagaMiddleware.run`', () => {
29 | expect(typeof store.runSaga).toBe('function');
30 | });
31 | });
32 | });
33 |
34 | describe('configureStore params', () => {
35 | it('should call window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__', () => {
36 | /* eslint-disable no-underscore-dangle */
37 | const compose = jest.fn();
38 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = () => compose;
39 | configureStore(undefined, browserHistory);
40 | expect(compose).toHaveBeenCalled();
41 | /* eslint-enable */
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/app/utils/checkStore.js:
--------------------------------------------------------------------------------
1 | import conformsTo from 'lodash/conformsTo';
2 | import isFunction from 'lodash/isFunction';
3 | import isObject from 'lodash/isObject';
4 | import invariant from 'invariant';
5 |
6 | /**
7 | * Validate the shape of redux store
8 | */
9 | export default function checkStore(store) {
10 | const shape = {
11 | dispatch: isFunction,
12 | subscribe: isFunction,
13 | getState: isFunction,
14 | replaceReducer: isFunction,
15 | runSaga: isFunction,
16 | injectedReducers: isObject,
17 | injectedSagas: isObject,
18 | };
19 | invariant(
20 | conformsTo(store, shape),
21 | '(app/utils...) injectors: Expected a valid redux store'
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/app/utils/constants.js:
--------------------------------------------------------------------------------
1 | export const RESTART_ON_REMOUNT = '@@saga-injector/restart-on-remount';
2 | export const DAEMON = '@@saga-injector/daemon';
3 | export const ONCE_TILL_UNMOUNT = '@@saga-injector/once-till-unmount';
4 |
--------------------------------------------------------------------------------
/app/utils/history.js:
--------------------------------------------------------------------------------
1 | import { createBrowserHistory } from 'history';
2 |
3 | const history = createBrowserHistory();
4 | export default history;
5 |
--------------------------------------------------------------------------------
/app/utils/injectReducer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import hoistNonReactStatics from 'hoist-non-react-statics';
3 | import { ReactReduxContext } from 'react-redux';
4 |
5 | import getInjectors from './reducerInjectors';
6 |
7 | /**
8 | * Dynamically injects a reducer
9 | */
10 | export default ({ key, reducer }) => (WrappedComponent) => {
11 | class ReducerInjector extends React.Component {
12 | static WrappedComponent = WrappedComponent;
13 |
14 | static contextType = ReactReduxContext;
15 |
16 | static displayName = `withReducer(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
17 |
18 | constructor(props, context) {
19 | super(props, context);
20 |
21 | getInjectors(context.store).injectReducer(key, reducer);
22 | }
23 |
24 | render() {
25 | return ;
26 | }
27 | }
28 |
29 | return hoistNonReactStatics(ReducerInjector, WrappedComponent);
30 | };
31 |
--------------------------------------------------------------------------------
/app/utils/injectSaga.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import hoistNonReactStatics from 'hoist-non-react-statics';
3 | import { ReactReduxContext } from 'react-redux';
4 |
5 | import getInjectors from './sagaInjectors';
6 |
7 | /**
8 | * Dynamically injects a saga, passes component's props as saga arguments
9 | */
10 | export default ({ key, saga, mode }) => (WrappedComponent) => {
11 | class InjectSaga extends React.Component {
12 | static WrappedComponent = WrappedComponent;
13 |
14 | static contextType = ReactReduxContext;
15 |
16 | static displayName = `withSaga(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
17 |
18 | injectors = getInjectors(this.context.store); // eslint-disable-line react/destructuring-assignment
19 |
20 | constructor(props, context) {
21 | super(props, context);
22 | this.injectors = getInjectors(context.store);
23 | this.injectors.injectSaga(key, { saga, mode }, this.props);
24 | }
25 |
26 | componentWillUnmount() {
27 | this.injectors.ejectSaga(key);
28 | }
29 |
30 | render() {
31 | return ;
32 | }
33 | }
34 |
35 | return hoistNonReactStatics(InjectSaga, WrappedComponent);
36 | };
37 |
--------------------------------------------------------------------------------
/app/utils/reducerInjectors.js:
--------------------------------------------------------------------------------
1 | import invariant from 'invariant';
2 | import isEmpty from 'lodash/isEmpty';
3 | import isFunction from 'lodash/isFunction';
4 | import isString from 'lodash/isString';
5 |
6 | import checkStore from './checkStore';
7 | import createReducer from '../reducers';
8 |
9 | export function injectReducerFactory(store, isValid) {
10 | return function injectReducer(key, reducer) {
11 | if (!isValid) checkStore(store);
12 |
13 | invariant(
14 | isString(key) && !isEmpty(key) && isFunction(reducer),
15 | '(app/utils...) injectReducer: Expected `reducer` to be a reducer function',
16 | );
17 |
18 | // Check `store.injectedReducers[key] === reducer` for hot reloading when a key is the same but a reducer is different
19 | if (Reflect.has(store.injectedReducers, key) && store.injectedReducers[key] === reducer) return;
20 |
21 | store.injectedReducers[key] = reducer; // eslint-disable-line no-param-reassign
22 | store.replaceReducer(createReducer(store.injectedReducers));
23 | };
24 | }
25 |
26 | export default function getInjectors(store) {
27 | checkStore(store);
28 |
29 | return {
30 | injectReducer: injectReducerFactory(store, true),
31 | };
32 | }
33 |
--------------------------------------------------------------------------------
/app/utils/request.js:
--------------------------------------------------------------------------------
1 | import 'whatwg-fetch';
2 |
3 | /**
4 | * Parses the JSON returned by a network request
5 | *
6 | * @param {object} response A response from a network request
7 | *
8 | * @return {object} The parsed JSON from the request
9 | */
10 | function parseJSON(response) {
11 | if (response.status === 204 || response.status === 205) {
12 | return null;
13 | }
14 | return response.json();
15 | }
16 |
17 | /**
18 | * Checks if a network request came back fine, and throws an error if not
19 | *
20 | * @param {object} response A response from a network request
21 | *
22 | * @return {object|undefined} Returns either the response, or throws an error
23 | */
24 | function checkStatus(response) {
25 | if (response.status >= 200 && response.status < 300) {
26 | return response;
27 | }
28 |
29 | const error = new Error(response.statusText);
30 | error.response = response;
31 | throw error;
32 | }
33 |
34 | /**
35 | * Requests a URL, returning a promise
36 | *
37 | * @param {string} url The URL we want to request
38 | * @param {object} [options] The options we want to pass to "fetch"
39 | *
40 | * @return {object} The response data
41 | */
42 | export default function request(url, options) {
43 | return fetch(url, options)
44 | .then(checkStatus)
45 | .then(parseJSON);
46 | }
47 |
--------------------------------------------------------------------------------
/app/utils/sagaInjectors.js:
--------------------------------------------------------------------------------
1 | import isEmpty from 'lodash/isEmpty';
2 | import isFunction from 'lodash/isFunction';
3 | import isString from 'lodash/isString';
4 | import invariant from 'invariant';
5 | import conformsTo from 'lodash/conformsTo';
6 |
7 | import checkStore from './checkStore';
8 | import {
9 | DAEMON,
10 | ONCE_TILL_UNMOUNT,
11 | RESTART_ON_REMOUNT,
12 | } from './constants';
13 |
14 | const allowedModes = [RESTART_ON_REMOUNT, DAEMON, ONCE_TILL_UNMOUNT];
15 |
16 | const checkKey = (key) => invariant(
17 | isString(key) && !isEmpty(key),
18 | '(app/utils...) injectSaga: Expected `key` to be a non empty string'
19 | );
20 |
21 | const checkDescriptor = (descriptor) => {
22 | const shape = {
23 | saga: isFunction,
24 | mode: (mode) => isString(mode) && allowedModes.includes(mode),
25 | };
26 | invariant(
27 | conformsTo(descriptor, shape),
28 | '(app/utils...) injectSaga: Expected a valid saga descriptor'
29 | );
30 | };
31 |
32 | export function injectSagaFactory(store, isValid) {
33 | return function injectSaga(key, descriptor = {}, args) {
34 | if (!isValid) checkStore(store);
35 |
36 | const newDescriptor = { ...descriptor, mode: descriptor.mode || RESTART_ON_REMOUNT };
37 | const { saga, mode } = newDescriptor;
38 |
39 | checkKey(key);
40 | checkDescriptor(newDescriptor);
41 |
42 | let hasSaga = Reflect.has(store.injectedSagas, key);
43 |
44 | if (process.env.NODE_ENV !== 'production') {
45 | const oldDescriptor = store.injectedSagas[key];
46 | // enable hot reloading of daemon and once-till-unmount sagas
47 | if (hasSaga && oldDescriptor.saga !== saga) {
48 | oldDescriptor.task.cancel();
49 | hasSaga = false;
50 | }
51 | }
52 |
53 | if (!hasSaga || (hasSaga && mode !== DAEMON && mode !== ONCE_TILL_UNMOUNT)) {
54 | store.injectedSagas[key] = { ...newDescriptor, task: store.runSaga(saga, args) }; // eslint-disable-line no-param-reassign
55 | }
56 | };
57 | }
58 |
59 | export function ejectSagaFactory(store, isValid) {
60 | return function ejectSaga(key) {
61 | if (!isValid) checkStore(store);
62 |
63 | checkKey(key);
64 |
65 | if (Reflect.has(store.injectedSagas, key)) {
66 | const descriptor = store.injectedSagas[key];
67 | if (descriptor.mode !== DAEMON) {
68 | descriptor.task.cancel();
69 | // Clean up in production; in development we need `descriptor.saga` for hot reloading
70 | if (process.env.NODE_ENV === 'production') {
71 | // Need some value to be able to detect `ONCE_TILL_UNMOUNT` sagas in `injectSaga`
72 | store.injectedSagas[key] = 'done'; // eslint-disable-line no-param-reassign
73 | }
74 | }
75 | }
76 | };
77 | }
78 |
79 | export default function getInjectors(store) {
80 | checkStore(store);
81 |
82 | return {
83 | injectSaga: injectSagaFactory(store, true),
84 | ejectSaga: ejectSagaFactory(store, true),
85 | };
86 | }
87 |
--------------------------------------------------------------------------------
/app/utils/tests/checkStore.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Test injectors
3 | */
4 |
5 | import checkStore from '../checkStore';
6 |
7 | describe('checkStore', () => {
8 | let store;
9 |
10 | beforeEach(() => {
11 | store = {
12 | dispatch: () => {},
13 | subscribe: () => {},
14 | getState: () => {},
15 | replaceReducer: () => {},
16 | runSaga: () => {},
17 | injectedReducers: {},
18 | injectedSagas: {},
19 | };
20 | });
21 |
22 | it('should not throw if passed valid store shape', () => {
23 | expect(() => checkStore(store)).not.toThrow();
24 | });
25 |
26 | it('should throw if passed invalid store shape', () => {
27 | expect(() => checkStore({})).toThrow();
28 | expect(() => checkStore({ ...store, injectedSagas: null })).toThrow();
29 | expect(() => checkStore({ ...store, injectedReducers: null })).toThrow();
30 | expect(() => checkStore({ ...store, runSaga: null })).toThrow();
31 | expect(() => checkStore({ ...store, replaceReducer: null })).toThrow();
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/app/utils/tests/injectReducer.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Test injectors
3 | */
4 |
5 | import { memoryHistory } from 'react-router-dom';
6 | import { shallow } from 'enzyme';
7 | import React from 'react';
8 | import identity from 'lodash/identity';
9 |
10 | import configureStore from '../../configureStore';
11 | import injectReducer from '../injectReducer';
12 | import * as reducerInjectors from '../reducerInjectors';
13 |
14 | // Fixtures
15 | const Component = () => null;
16 |
17 | const reducer = identity;
18 |
19 | describe('injectReducer decorator', () => {
20 | let store;
21 | let injectors;
22 | let ComponentWithReducer;
23 |
24 | beforeAll(() => {
25 | reducerInjectors.default = jest.fn().mockImplementation(() => injectors);
26 | });
27 |
28 | beforeEach(() => {
29 | store = configureStore({}, memoryHistory);
30 | injectors = {
31 | injectReducer: jest.fn(),
32 | };
33 | ComponentWithReducer = injectReducer({ key: 'test', reducer })(Component);
34 | reducerInjectors.default.mockClear();
35 | });
36 |
37 | it('should inject a given reducer', () => {
38 | shallow( , { context: { store } });
39 |
40 | expect(injectors.injectReducer).toHaveBeenCalledTimes(1);
41 | expect(injectors.injectReducer).toHaveBeenCalledWith('test', reducer);
42 | });
43 |
44 | it('should set a correct display name', () => {
45 | expect(ComponentWithReducer.displayName).toBe('withReducer(Component)');
46 | expect(injectReducer({ key: 'test', reducer })(() => null).displayName).toBe('withReducer(Component)');
47 | });
48 |
49 | it('should propagate props', () => {
50 | const props = { testProp: 'test' };
51 | const renderedComponent = shallow( , { context: { store } });
52 |
53 | expect(renderedComponent.prop('testProp')).toBe('test');
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/app/utils/tests/injectSaga.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Test injectors
3 | */
4 |
5 | import { memoryHistory } from 'react-router-dom';
6 | import { put } from 'redux-saga/effects';
7 | import { shallow } from 'enzyme';
8 | import React from 'react';
9 |
10 | import configureStore from '../../configureStore';
11 | import injectSaga from '../injectSaga';
12 | import * as sagaInjectors from '../sagaInjectors';
13 |
14 | // Fixtures
15 | const Component = () => null;
16 |
17 | function* testSaga() {
18 | yield put({ type: 'TEST', payload: 'yup' });
19 | }
20 |
21 | describe('injectSaga decorator', () => {
22 | let store;
23 | let injectors;
24 | let ComponentWithSaga;
25 |
26 | beforeAll(() => {
27 | sagaInjectors.default = jest.fn().mockImplementation(() => injectors);
28 | });
29 |
30 | beforeEach(() => {
31 | store = configureStore({}, memoryHistory);
32 | injectors = {
33 | injectSaga: jest.fn(),
34 | ejectSaga: jest.fn(),
35 | };
36 | ComponentWithSaga = injectSaga({ key: 'test', saga: testSaga, mode: 'testMode' })(Component);
37 | sagaInjectors.default.mockClear();
38 | });
39 |
40 | it('should inject given saga, mode, and props', () => {
41 | const props = { test: 'test' };
42 | shallow( , { context: { store } });
43 |
44 | expect(injectors.injectSaga).toHaveBeenCalledTimes(1);
45 | expect(injectors.injectSaga).toHaveBeenCalledWith('test', { saga: testSaga, mode: 'testMode' }, props);
46 | });
47 |
48 | it('should eject on unmount with a correct saga key', () => {
49 | const props = { test: 'test' };
50 | const renderedComponent = shallow( , { context: { store } });
51 | renderedComponent.unmount();
52 |
53 | expect(injectors.ejectSaga).toHaveBeenCalledTimes(1);
54 | expect(injectors.ejectSaga).toHaveBeenCalledWith('test');
55 | });
56 |
57 | it('should set a correct display name', () => {
58 | expect(ComponentWithSaga.displayName).toBe('withSaga(Component)');
59 | expect(injectSaga({ key: 'test', saga: testSaga })(() => null).displayName).toBe('withSaga(Component)');
60 | });
61 |
62 | it('should propagate props', () => {
63 | const props = { testProp: 'test' };
64 | const renderedComponent = shallow( , { context: { store } });
65 |
66 | expect(renderedComponent.prop('testProp')).toBe('test');
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/app/utils/tests/reducerInjectors.test.js:
--------------------------------------------------------------------------------
1 | import { memoryHistory } from 'react-router-dom';
2 | import identity from 'lodash/identity';
3 |
4 | import configureStore from '../../configureStore';
5 |
6 | import getInjectors, { injectReducerFactory } from '../reducerInjectors';
7 |
8 | const initialState = { reduced: 'soon' };
9 |
10 | const reducer = (state = initialState, action) => {
11 | switch (action.type) {
12 | case 'TEST':
13 | return { ...state, reduced: action.payload };
14 | default:
15 | return state;
16 | }
17 | };
18 |
19 | describe('reducer injectors', () => {
20 | let store;
21 | let injectReducer;
22 |
23 | describe('getInjectors', () => {
24 | beforeEach(() => {
25 | store = configureStore({}, memoryHistory);
26 | });
27 |
28 | it('should return injectors', () => {
29 | expect(getInjectors(store)).toEqual(
30 | expect.objectContaining({
31 | injectReducer: expect.any(Function),
32 | }),
33 | );
34 | });
35 |
36 | it('should throw if passed invalid store shape', () => {
37 | Reflect.deleteProperty(store, 'dispatch');
38 |
39 | expect(() => getInjectors(store)).toThrow();
40 | });
41 | });
42 |
43 | describe('injectReducer helper', () => {
44 | beforeEach(() => {
45 | store = configureStore({}, memoryHistory);
46 | injectReducer = injectReducerFactory(store, true);
47 | });
48 |
49 | it('should check a store if the second argument is falsy', () => {
50 | const inject = injectReducerFactory({});
51 |
52 | expect(() => inject('test', reducer)).toThrow();
53 | });
54 |
55 | it('it should not check a store if the second argument is true', () => {
56 | expect(() => injectReducer('test', reducer)).not.toThrow();
57 | });
58 |
59 | it("should validate a reducer and reducer's key", () => {
60 | expect(() => injectReducer('', reducer)).toThrow();
61 | expect(() => injectReducer(1, reducer)).toThrow();
62 | expect(() => injectReducer(1, 1)).toThrow();
63 | });
64 |
65 | it('given a store, it should provide a function to inject a reducer', () => {
66 | injectReducer('test', reducer);
67 |
68 | const actual = store.getState().test;
69 | const expected = initialState;
70 |
71 | expect(actual).toEqual(expected);
72 | });
73 |
74 | it('should not assign reducer if already existing', () => {
75 | store.replaceReducer = jest.fn();
76 | injectReducer('test', reducer);
77 | injectReducer('test', reducer);
78 |
79 | expect(store.replaceReducer).toHaveBeenCalledTimes(1);
80 | });
81 |
82 | it('should assign reducer if different implementation for hot reloading', () => {
83 | store.replaceReducer = jest.fn();
84 | injectReducer('test', reducer);
85 | injectReducer('test', identity);
86 |
87 | expect(store.replaceReducer).toHaveBeenCalledTimes(2);
88 | });
89 | });
90 | });
91 |
--------------------------------------------------------------------------------
/app/utils/tests/request.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Test the request function
3 | */
4 |
5 | import request from '../request';
6 |
7 | describe('request', () => {
8 | // Before each test, stub the fetch function
9 | beforeEach(() => {
10 | window.fetch = jest.fn();
11 | });
12 |
13 | describe('stubbing successful response', () => {
14 | // Before each test, pretend we got a successful response
15 | beforeEach(() => {
16 | const res = new Response('{"hello":"world"}', {
17 | status: 200,
18 | headers: {
19 | 'Content-type': 'application/json',
20 | },
21 | });
22 |
23 | window.fetch.mockReturnValue(Promise.resolve(res));
24 | });
25 |
26 | it('should format the response correctly', (done) => {
27 | request('/thisurliscorrect')
28 | .catch(done)
29 | .then((json) => {
30 | expect(json.hello).toBe('world');
31 | done();
32 | });
33 | });
34 | });
35 |
36 | describe('stubbing 204 response', () => {
37 | // Before each test, pretend we got a successful response
38 | beforeEach(() => {
39 | const res = new Response('', {
40 | status: 204,
41 | statusText: 'No Content',
42 | });
43 |
44 | window.fetch.mockReturnValue(Promise.resolve(res));
45 | });
46 |
47 | it('should return null on 204 response', (done) => {
48 | request('/thisurliscorrect')
49 | .catch(done)
50 | .then((json) => {
51 | expect(json).toBeNull();
52 | done();
53 | });
54 | });
55 | });
56 |
57 | describe('stubbing error response', () => {
58 | // Before each test, pretend we got an unsuccessful response
59 | beforeEach(() => {
60 | const res = new Response('', {
61 | status: 404,
62 | statusText: 'Not Found',
63 | headers: {
64 | 'Content-type': 'application/json',
65 | },
66 | });
67 |
68 | window.fetch.mockReturnValue(Promise.resolve(res));
69 | });
70 |
71 | it('should catch errors', (done) => {
72 | request('/thisdoesntexist')
73 | .catch((err) => {
74 | expect(err.response.status).toBe(404);
75 | expect(err.response.statusText).toBe('Not Found');
76 | done();
77 | });
78 | });
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/app/utils/tests/sagaInjectors.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Test injectors
3 | */
4 |
5 | import { memoryHistory } from 'react-router-dom';
6 | import { put } from 'redux-saga/effects';
7 |
8 | import configureStore from '../../configureStore';
9 | import getInjectors, {
10 | injectSagaFactory,
11 | ejectSagaFactory,
12 | } from '../sagaInjectors';
13 | import {
14 | DAEMON,
15 | ONCE_TILL_UNMOUNT,
16 | RESTART_ON_REMOUNT,
17 | } from '../constants';
18 |
19 | function* testSaga() {
20 | yield put({ type: 'TEST', payload: 'yup' });
21 | }
22 |
23 | describe('injectors', () => {
24 | const originalNodeEnv = process.env.NODE_ENV;
25 | let store;
26 | let injectSaga;
27 | let ejectSaga;
28 |
29 | describe('getInjectors', () => {
30 | beforeEach(() => {
31 | store = configureStore({}, memoryHistory);
32 | });
33 |
34 | it('should return injectors', () => {
35 | expect(getInjectors(store)).toEqual(expect.objectContaining({
36 | injectSaga: expect.any(Function),
37 | ejectSaga: expect.any(Function),
38 | }));
39 | });
40 |
41 | it('should throw if passed invalid store shape', () => {
42 | Reflect.deleteProperty(store, 'dispatch');
43 |
44 | expect(() => getInjectors(store)).toThrow();
45 | });
46 | });
47 |
48 | describe('ejectSaga helper', () => {
49 | beforeEach(() => {
50 | store = configureStore({}, memoryHistory);
51 | injectSaga = injectSagaFactory(store, true);
52 | ejectSaga = ejectSagaFactory(store, true);
53 | });
54 |
55 | it('should check a store if the second argument is falsy', () => {
56 | const eject = ejectSagaFactory({});
57 |
58 | expect(() => eject('test')).toThrow();
59 | });
60 |
61 | it('should not check a store if the second argument is true', () => {
62 | Reflect.deleteProperty(store, 'dispatch');
63 | injectSaga('test', { saga: testSaga });
64 |
65 | expect(() => ejectSaga('test')).not.toThrow();
66 | });
67 |
68 | it('should validate saga\'s key', () => {
69 | expect(() => ejectSaga('')).toThrow();
70 | expect(() => ejectSaga(1)).toThrow();
71 | });
72 |
73 | it('should cancel a saga in a default mode', () => {
74 | const cancel = jest.fn();
75 | store.injectedSagas.test = { task: { cancel } };
76 | ejectSaga('test');
77 |
78 | expect(cancel).toHaveBeenCalled();
79 | });
80 |
81 | it('should not cancel a daemon saga', () => {
82 | const cancel = jest.fn();
83 | store.injectedSagas.test = { task: { cancel }, mode: DAEMON };
84 | ejectSaga('test');
85 |
86 | expect(cancel).not.toHaveBeenCalled();
87 | });
88 |
89 | it('should ignore saga that was not previously injected', () => {
90 | expect(() => ejectSaga('test')).not.toThrow();
91 | });
92 |
93 | it('should remove non daemon saga\'s descriptor in production', () => {
94 | process.env.NODE_ENV = 'production';
95 | injectSaga('test', { saga: testSaga, mode: RESTART_ON_REMOUNT });
96 | injectSaga('test1', { saga: testSaga, mode: ONCE_TILL_UNMOUNT });
97 |
98 | ejectSaga('test');
99 | ejectSaga('test1');
100 |
101 | expect(store.injectedSagas.test).toBe('done');
102 | expect(store.injectedSagas.test1).toBe('done');
103 | process.env.NODE_ENV = originalNodeEnv;
104 | });
105 |
106 | it('should not remove daemon saga\'s descriptor in production', () => {
107 | process.env.NODE_ENV = 'production';
108 | injectSaga('test', { saga: testSaga, mode: DAEMON });
109 | ejectSaga('test');
110 |
111 | expect(store.injectedSagas.test.saga).toBe(testSaga);
112 | process.env.NODE_ENV = originalNodeEnv;
113 | });
114 |
115 | it('should not remove daemon saga\'s descriptor in development', () => {
116 | injectSaga('test', { saga: testSaga, mode: DAEMON });
117 | ejectSaga('test');
118 |
119 | expect(store.injectedSagas.test.saga).toBe(testSaga);
120 | });
121 | });
122 |
123 | describe('injectSaga helper', () => {
124 | beforeEach(() => {
125 | store = configureStore({}, memoryHistory);
126 | injectSaga = injectSagaFactory(store, true);
127 | ejectSaga = ejectSagaFactory(store, true);
128 | });
129 |
130 | it('should check a store if the second argument is falsy', () => {
131 | const inject = injectSagaFactory({});
132 |
133 | expect(() => inject('test', testSaga)).toThrow();
134 | });
135 |
136 | it('it should not check a store if the second argument is true', () => {
137 | Reflect.deleteProperty(store, 'dispatch');
138 |
139 | expect(() => injectSaga('test', { saga: testSaga })).not.toThrow();
140 | });
141 |
142 | it('should validate saga\'s key', () => {
143 | expect(() => injectSaga('', { saga: testSaga })).toThrow();
144 | expect(() => injectSaga(1, { saga: testSaga })).toThrow();
145 | });
146 |
147 | it('should validate saga\'s descriptor', () => {
148 | expect(() => injectSaga('test')).toThrow();
149 | expect(() => injectSaga('test', { saga: 1 })).toThrow();
150 | expect(() => injectSaga('test', { saga: testSaga, mode: 'testMode' })).toThrow();
151 | expect(() => injectSaga('test', { saga: testSaga, mode: 1 })).toThrow();
152 | expect(() => injectSaga('test', { saga: testSaga, mode: RESTART_ON_REMOUNT })).not.toThrow();
153 | expect(() => injectSaga('test', { saga: testSaga, mode: DAEMON })).not.toThrow();
154 | expect(() => injectSaga('test', { saga: testSaga, mode: ONCE_TILL_UNMOUNT })).not.toThrow();
155 | });
156 |
157 | it('should pass args to saga.run', () => {
158 | const args = {};
159 | store.runSaga = jest.fn();
160 | injectSaga('test', { saga: testSaga }, args);
161 |
162 | expect(store.runSaga).toHaveBeenCalledWith(testSaga, args);
163 | });
164 |
165 | it('should not start daemon and once-till-unmount sagas if were started before', () => {
166 | store.runSaga = jest.fn();
167 |
168 | injectSaga('test1', { saga: testSaga, mode: DAEMON });
169 | injectSaga('test1', { saga: testSaga, mode: DAEMON });
170 | injectSaga('test2', { saga: testSaga, mode: ONCE_TILL_UNMOUNT });
171 | injectSaga('test2', { saga: testSaga, mode: ONCE_TILL_UNMOUNT });
172 |
173 | expect(store.runSaga).toHaveBeenCalledTimes(2);
174 | });
175 |
176 | it('should start any saga that was not started before', () => {
177 | store.runSaga = jest.fn();
178 |
179 | injectSaga('test1', { saga: testSaga });
180 | injectSaga('test2', { saga: testSaga, mode: DAEMON });
181 | injectSaga('test3', { saga: testSaga, mode: ONCE_TILL_UNMOUNT });
182 |
183 | expect(store.runSaga).toHaveBeenCalledTimes(3);
184 | });
185 |
186 | it('should restart a saga if different implementation for hot reloading', () => {
187 | const cancel = jest.fn();
188 | store.injectedSagas.test = { saga: testSaga, task: { cancel } };
189 | store.runSaga = jest.fn();
190 |
191 | function* testSaga1() {
192 | yield put({ type: 'TEST', payload: 'yup' });
193 | }
194 |
195 | injectSaga('test', { saga: testSaga1 });
196 |
197 | expect(cancel).toHaveBeenCalledTimes(1);
198 | expect(store.runSaga).toHaveBeenCalledWith(testSaga1, undefined);
199 | });
200 |
201 | it('should not cancel saga if different implementation in production', () => {
202 | process.env.NODE_ENV = 'production';
203 | const cancel = jest.fn();
204 | store.injectedSagas.test = { saga: testSaga, task: { cancel }, mode: RESTART_ON_REMOUNT };
205 |
206 | function* testSaga1() {
207 | yield put({ type: 'TEST', payload: 'yup' });
208 | }
209 |
210 | injectSaga('test', { saga: testSaga1, mode: DAEMON });
211 |
212 | expect(cancel).toHaveBeenCalledTimes(0);
213 | process.env.NODE_ENV = originalNodeEnv;
214 | });
215 |
216 | it('should save an entire descriptor in the saga registry', () => {
217 | injectSaga('test', { saga: testSaga, foo: 'bar' });
218 | expect(store.injectedSagas.test.foo).toBe('bar');
219 | });
220 | });
221 | });
222 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | [
4 | '@babel/preset-env',
5 | {
6 | modules: false
7 | }
8 | ],
9 | '@babel/preset-react'
10 | ],
11 | plugins: ['@babel/plugin-proposal-class-properties', '@babel/plugin-syntax-dynamic-import'],
12 | env: {
13 | production: {
14 | only: ['app'],
15 | plugins: [
16 | 'lodash',
17 | 'transform-react-remove-prop-types',
18 | '@babel/plugin-transform-react-inline-elements',
19 | '@babel/plugin-transform-react-constant-elements'
20 | ]
21 | },
22 | test: {
23 | plugins: ['@babel/plugin-transform-modules-commonjs', 'dynamic-import-node']
24 | }
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/config/jest-mocks/cssModule.js:
--------------------------------------------------------------------------------
1 | module.exports = 'CSS_MODULE';
2 |
--------------------------------------------------------------------------------
/config/jest-mocks/image.js:
--------------------------------------------------------------------------------
1 | module.exports = 'IMAGE_MOCK';
2 |
--------------------------------------------------------------------------------
/config/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | collectCoverageFrom: [
3 | 'app/**/*.{js,jsx}',
4 | '!app/**/*.test.{js,jsx}',
5 | '!app/*/RbGenerated*/*.{js,jsx}',
6 | '!app/app.js',
7 | '!app/*/*/Loadable.{js,jsx}',
8 | ],
9 | coverageThreshold: {
10 | global: {
11 | statements: 98,
12 | branches: 91,
13 | functions: 98,
14 | lines: 98,
15 | },
16 | },
17 | coverageReporters: ['json', 'lcov', 'text-summary'],
18 | moduleDirectories: ['node_modules', 'app'],
19 | transform: {
20 | '^.+\\.(js|jsx)$': '/node_modules/babel-jest',
21 | },
22 | transformIgnorePatterns: [
23 | '[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$',
24 | '^.+\\.module\\.(css|sass|scss)$',
25 | ],
26 | moduleNameMapper: {
27 | '.*\\.(css|less|styl|scss|sass)$':
28 | '/config/jest-mocks/cssModule.js',
29 | '.*\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
30 | '/config/jest-mocks/image.js',
31 | },
32 | setupFilesAfterEnv: ['/config/test-setup.js'],
33 | testRegex: 'tests/.*\\.test\\.js$',
34 | };
35 |
--------------------------------------------------------------------------------
/config/test-setup.js:
--------------------------------------------------------------------------------
1 | // needed for regenerator-runtime
2 | // (ES7 generator support is required by redux-saga)
3 | import '@babel/polyfill';
4 |
5 | // Enzyme adapter for React 16
6 | import Enzyme from 'enzyme';
7 | import Adapter from 'enzyme-adapter-react-16';
8 |
9 | Enzyme.configure({ adapter: new Adapter() });
10 |
--------------------------------------------------------------------------------
/config/webpack.base.babel.js:
--------------------------------------------------------------------------------
1 | /**
2 | * COMMON WEBPACK CONFIGURATION
3 | */
4 |
5 | const path = require('path');
6 | const webpack = require('webpack');
7 |
8 | process.noDeprecation = true;
9 |
10 | module.exports = (options) => ({
11 | mode: options.mode,
12 | entry: options.entry,
13 | output: Object.assign(
14 | {
15 | // Compile into js/build.js
16 | path: path.resolve(process.cwd(), 'build'),
17 | publicPath: '/'
18 | },
19 | options.output
20 | ), // Merge with env dependent settings
21 | module: {
22 | rules: [
23 | {
24 | test: /\.js$/, // Transform all .js files required somewhere with Babel
25 | exclude: /node_modules/,
26 | use: {
27 | loader: 'babel-loader',
28 | options: options.babelQuery
29 | }
30 | },
31 | {
32 | // Preprocess our own .scss files
33 | test: /\.scss$/,
34 | exclude: /node_modules/,
35 | use: ['style-loader', 'css-loader', 'sass-loader']
36 | },
37 | {
38 | // Preprocess 3rd party .css files located in node_modules
39 | test: /\.css$/,
40 | include: /node_modules/,
41 | use: ['style-loader', 'css-loader']
42 | },
43 | {
44 | test: /\.(eot|svg|otf|ttf|woff|woff2)$/,
45 | use: 'file-loader'
46 | },
47 | {
48 | test: /\.(jpg|png|gif)$/,
49 | use: 'file-loader'
50 | },
51 | {
52 | test: /\.html$/,
53 | use: 'html-loader'
54 | },
55 | {
56 | test: /\.(mp4|webm)$/,
57 | use: {
58 | loader: 'url-loader',
59 | options: {
60 | limit: 10000
61 | }
62 | }
63 | }
64 | ]
65 | },
66 | plugins: options.plugins.concat([
67 | new webpack.ProvidePlugin({
68 | // make fetch available
69 | fetch: 'exports-loader?self.fetch!whatwg-fetch'
70 | }),
71 |
72 | // Always expose NODE_ENV to webpack, in order to use `process.env.NODE_ENV`
73 | // inside your code for any environment checks; UglifyJS will automatically
74 | // drop any unreachable code.
75 | new webpack.DefinePlugin({
76 | 'process.env': {
77 | NODE_ENV: JSON.stringify(process.env.NODE_ENV)
78 | }
79 | })
80 | ]),
81 | resolve: {
82 | modules: ['app', 'node_modules'],
83 | extensions: ['.js', '.jsx', '.scss', '.react.js'],
84 | mainFields: ['browser', 'jsnext:main', 'main']
85 | },
86 | devtool: options.devtool,
87 | target: 'web', // Make web variables accessible to webpack, e.g. window
88 | performance: options.performance || {},
89 | optimization: {
90 | namedModules: true,
91 | splitChunks: {
92 | name: 'vendor',
93 | minChunks: 2
94 | }
95 | }
96 | });
97 |
--------------------------------------------------------------------------------
/config/webpack.dev.babel.js:
--------------------------------------------------------------------------------
1 | /**
2 | * DEVELOPMENT WEBPACK CONFIGURATION
3 | */
4 |
5 | const path = require('path');
6 | const webpack = require('webpack');
7 | const HtmlWebpackPlugin = require('html-webpack-plugin');
8 | const CircularDependencyPlugin = require('circular-dependency-plugin');
9 |
10 | module.exports = require('./webpack.base.babel')({
11 | mode: 'development',
12 | // Add hot reloading in development
13 | entry: [
14 | 'eventsource-polyfill', // Necessary for hot reloading with IE
15 | 'webpack-hot-middleware/client?reload=true',
16 | path.join(process.cwd(), 'app/app.js') // Start with js/app.js
17 | ],
18 |
19 | // Don't use hashes in dev mode for better performance
20 | output: {
21 | filename: '[name].js',
22 | chunkFilename: '[name].chunk.js'
23 | },
24 |
25 | // Add development plugins
26 | plugins: [
27 | new webpack.HotModuleReplacementPlugin(), // Tell webpack we want hot reloading
28 | new HtmlWebpackPlugin({
29 | inject: true, // Inject all files that are generated by webpack, e.g. bundle.js
30 | template: 'app/index.html'
31 | }),
32 | new CircularDependencyPlugin({
33 | exclude: /a\.js|node_modules/, // exclude node_modules
34 | failOnError: false // show a warning when there is a circular dependency
35 | })
36 | ],
37 |
38 | // Emit a source map for easier debugging
39 | // See https://webpack.js.org/configuration/devtool/#devtool
40 | devtool: 'eval-source-map',
41 |
42 | performance: {
43 | hints: false
44 | }
45 | });
46 |
--------------------------------------------------------------------------------
/config/webpack.prod.babel.js:
--------------------------------------------------------------------------------
1 | // Important modules this config uses
2 | const path = require('path');
3 | // const webpack = require('webpack');
4 | const HtmlWebpackPlugin = require('html-webpack-plugin');
5 |
6 | module.exports = require('./webpack.base.babel')({
7 | mode: 'production',
8 | // In production, we skip all hot-reloading stuff
9 | entry: [
10 | path.join(process.cwd(), 'app/app.js')
11 | ],
12 |
13 | // Utilize long-term caching by adding content hashes (not compilation hashes) to compiled assets
14 | output: {
15 | filename: '[name].[chunkhash].js',
16 | chunkFilename: '[name].[chunkhash].chunk.js'
17 | },
18 |
19 | plugins: [
20 |
21 | // Minify and optimize the index.html
22 | new HtmlWebpackPlugin({
23 | template: 'app/index.html',
24 | minify: {
25 | removeComments: true,
26 | collapseWhitespace: true,
27 | removeRedundantAttributes: true,
28 | useShortDoctype: true,
29 | removeEmptyAttributes: true,
30 | removeStyleLinkTypeAttributes: true,
31 | keepClosingSlash: true,
32 | minifyJS: true,
33 | minifyCSS: true,
34 | minifyURLs: true,
35 | },
36 | inject: true
37 | }),
38 | ],
39 |
40 | performance: {
41 | assetFilter: (assetFilename) => !(/(\.map$)|(^(main\.|favicon\.))/.test(assetFilename)),
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./config/jest.config');
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-redux-boilerplate",
3 | "version": "1.0.0",
4 | "description": "A minimal react-redux boilerplate with all the best practices",
5 | "repository": {
6 | "type": "git",
7 | "url": "git://github.com/flexdinesh/react-redux-boilerplate.git"
8 | },
9 | "license": "MIT",
10 | "author": "Dinesh Pandiyan",
11 | "scripts": {
12 | "prebuild": "npm run build:clean",
13 | "build": "cross-env NODE_ENV=production webpack --config config/webpack.prod.babel.js --color -p --progress --hide-modules --display-optimization-bailout",
14 | "build:clean": "rimraf ./build",
15 | "clean": "npm run test:clean && npm run build:clean",
16 | "eslint:fix": "eslint -- . --ignore-path .gitignore --fix",
17 | "lint": "npm run lint:js",
18 | "lint:eslint": "eslint --ignore-path .gitignore",
19 | "lint:js": "npm run lint:eslint -- . ",
20 | "start": "cross-env NODE_ENV=development node server",
21 | "start:prod": "cross-env NODE_ENV=production node server",
22 | "start:production": "npm run test && npm run build && npm run start:prod",
23 | "pretest": "npm run test:clean && npm run lint",
24 | "test": "cross-env NODE_ENV=test jest --coverage",
25 | "test:clean": "rimraf ./coverage",
26 | "test:watch": "cross-env NODE_ENV=test jest --watchAll"
27 | },
28 | "dependencies": {
29 | "@babel/polyfill": "^7.4.4",
30 | "chalk": "^2.4.2",
31 | "compression": "1.7.4",
32 | "connected-react-router": "^6.4.0",
33 | "cross-env": "5.2.0",
34 | "express": "4.17.1",
35 | "fontfaceobserver": "2.1.0",
36 | "history": "4.9.0",
37 | "hoist-non-react-statics": "3.3.0",
38 | "invariant": "2.2.4",
39 | "ip": "1.1.5",
40 | "lodash": "4.17.11",
41 | "minimist": "1.2.0",
42 | "prop-types": "^15.7.2",
43 | "react": "^16.8.6",
44 | "react-dom": "^16.8.6",
45 | "react-helmet": "^5.2.1",
46 | "react-loadable": "^5.5.0",
47 | "react-redux": "7.1.0",
48 | "react-router-dom": "^5.0.1",
49 | "redux": "4.0.1",
50 | "redux-saga": "1.0.3",
51 | "reselect": "4.0.0",
52 | "sanitize.css": "10.0.0",
53 | "warning": "^4.0.3",
54 | "whatwg-fetch": "3.0.0"
55 | },
56 | "devDependencies": {
57 | "@babel/cli": "7.4.4",
58 | "@babel/core": "7.4.5",
59 | "@babel/plugin-proposal-class-properties": "7.4.4",
60 | "@babel/plugin-syntax-dynamic-import": "7.2.0",
61 | "@babel/plugin-transform-modules-commonjs": "7.4.4",
62 | "@babel/plugin-transform-react-constant-elements": "7.2.0",
63 | "@babel/plugin-transform-react-inline-elements": "7.2.0",
64 | "@babel/preset-env": "7.4.5",
65 | "@babel/preset-react": "7.0.0",
66 | "@babel/register": "^7.4.4",
67 | "add-asset-html-webpack-plugin": "3.1.3",
68 | "babel-core": "7.0.0-bridge.0",
69 | "babel-eslint": "10.0.2",
70 | "babel-loader": "8.0.6",
71 | "babel-plugin-dynamic-import-node": "2.2.0",
72 | "babel-plugin-lodash": "3.3.4",
73 | "babel-plugin-react-intl": "3.2.1",
74 | "babel-plugin-react-transform": "3.0.0",
75 | "babel-plugin-transform-react-remove-prop-types": "0.4.24",
76 | "circular-dependency-plugin": "5.0.2",
77 | "css-loader": "3.0.0",
78 | "enzyme": "^3.10.0",
79 | "enzyme-adapter-react-16": "^1.14.0",
80 | "eslint": "5.16.0",
81 | "eslint-config-airbnb": "17.1.0",
82 | "eslint-config-airbnb-base": "13.1.0",
83 | "eslint-import-resolver-webpack": "0.11.1",
84 | "eslint-plugin-import": "2.17.3",
85 | "eslint-plugin-jsx-a11y": "6.2.1",
86 | "eslint-plugin-react": "7.13.0",
87 | "eslint-plugin-redux-saga": "1.0.0",
88 | "eventsource-polyfill": "0.9.6",
89 | "exports-loader": "0.7.0",
90 | "file-loader": "4.0.0",
91 | "html-loader": "0.5.5",
92 | "html-webpack-plugin": "3.2.0",
93 | "imports-loader": "0.8.0",
94 | "jest-cli": "24.8.0",
95 | "lint-staged": "8.2.1",
96 | "node-plop": "0.18.0",
97 | "node-sass": "^4.12.0",
98 | "null-loader": "3.0.0",
99 | "plop": "2.3.0",
100 | "postcss-flexbugs-fixes": "^4.1.0",
101 | "postcss-loader": "^3.0.0",
102 | "react-test-renderer": "^16.8.6",
103 | "rimraf": "2.6.3",
104 | "sass-loader": "^7.1.0",
105 | "shelljs": "^0.8.3",
106 | "style-loader": "0.23.1",
107 | "url-loader": "2.0.0",
108 | "webpack": "4.34.0",
109 | "webpack-cli": "^3.3.4",
110 | "webpack-dev-middleware": "3.7.0",
111 | "webpack-hot-middleware": "2.25.0"
112 | },
113 | "resolutions": {
114 | "babel-core": "7.0.0-bridge.0"
115 | },
116 | "engines": {
117 | "node": ">=8.15.1",
118 | "npm": ">=5"
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | /* eslint consistent-return:0 */
2 |
3 | const express = require('express');
4 | const { resolve } = require('path');
5 | const logger = require('./util//logger');
6 |
7 | const argv = require('./util/argv');
8 | const port = require('./util//port');
9 | const setup = require('./middlewares/frontendMiddleware');
10 |
11 | const app = express();
12 |
13 | // If you need a backend, e.g. an API, add your custom backend-specific middleware here
14 | // app.use('/api', myApi);
15 |
16 | // In production we need to pass these values in instead of relying on webpack
17 | setup(app, {
18 | outputPath: resolve(process.cwd(), 'build'),
19 | publicPath: '/',
20 | });
21 |
22 | // get the intended host and port number, use localhost and port 3000 if not provided
23 | const customHost = argv.host || process.env.HOST;
24 | const host = customHost || null; // Let http.Server use its default IPv6/4 host
25 | const prettyHost = customHost || 'localhost';
26 |
27 | // Start your app.
28 | app.listen(port, host, (err) => {
29 | if (err) {
30 | return logger.error(err.message);
31 | }
32 | logger.appStarted(port, prettyHost);
33 | });
34 |
--------------------------------------------------------------------------------
/server/middlewares/addDevMiddlewares.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const webpackDevMiddleware = require('webpack-dev-middleware');
4 | const webpackHotMiddleware = require('webpack-hot-middleware');
5 |
6 | function createWebpackMiddleware(compiler, publicPath) {
7 | return webpackDevMiddleware(compiler, {
8 | noInfo: true,
9 | publicPath,
10 | silent: true,
11 | stats: 'errors-only'
12 | });
13 | }
14 |
15 | module.exports = function addDevMiddlewares(app, webpackConfig) {
16 | const compiler = webpack(webpackConfig);
17 | const middleware = createWebpackMiddleware(compiler, webpackConfig.output.publicPath);
18 |
19 | app.use(middleware);
20 | app.use(webpackHotMiddleware(compiler));
21 |
22 | // Since webpackDevMiddleware uses memory-fs internally to store build
23 | // artifacts, we use it instead
24 | const fs = middleware.fileSystem;
25 |
26 | app.get('*', (req, res) => {
27 | fs.readFile(path.join(compiler.outputPath, 'index.html'), (err, file) => {
28 | if (err) {
29 | res.sendStatus(404);
30 | } else {
31 | res.send(file.toString());
32 | }
33 | });
34 | });
35 | };
36 |
--------------------------------------------------------------------------------
/server/middlewares/addProdMiddlewares.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const express = require('express');
3 | const compression = require('compression');
4 |
5 | module.exports = function addProdMiddlewares(app, options) {
6 | const publicPath = options.publicPath || '/';
7 | const outputPath = options.outputPath || path.resolve(process.cwd(), 'build');
8 |
9 | // compression middleware compresses your server responses which makes them
10 | // smaller (applies also to assets). You can read more about that technique
11 | // and other good practices on official Express.js docs http://mxs.is/googmy
12 | app.use(compression());
13 | app.use(publicPath, express.static(outputPath));
14 |
15 | app.get('*', (req, res) => res.sendFile(path.resolve(outputPath, 'index.html')));
16 | };
17 |
--------------------------------------------------------------------------------
/server/middlewares/frontendMiddleware.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable global-require */
2 |
3 | /**
4 | * Front-end middleware
5 | */
6 | module.exports = (app, options) => {
7 | const isProd = process.env.NODE_ENV === 'production';
8 |
9 | if (isProd) {
10 | const addProdMiddlewares = require('./addProdMiddlewares');
11 | addProdMiddlewares(app, options);
12 | } else {
13 | const webpackConfig = require('../../config/webpack.dev.babel');
14 | const addDevMiddlewares = require('./addDevMiddlewares');
15 | addDevMiddlewares(app, webpackConfig);
16 | }
17 |
18 | return app;
19 | };
20 |
--------------------------------------------------------------------------------
/server/util/argv.js:
--------------------------------------------------------------------------------
1 | module.exports = require('minimist')(process.argv.slice(2));
2 |
--------------------------------------------------------------------------------
/server/util/logger.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 |
3 | const chalk = require('chalk');
4 | const ip = require('ip');
5 |
6 | const divider = chalk.gray('\n-----------------------------------');
7 |
8 | /**
9 | * Logger middleware, you can customize it to make messages more personal
10 | */
11 | const logger = {
12 | // Called whenever there's an error on the server we want to print
13 | error: (err) => {
14 | console.error(chalk.red(err));
15 | },
16 |
17 | // Called when express.js app starts on given port w/o errors
18 | appStarted: (port, host) => {
19 | console.log(`Server started ! ${chalk.green('✓')}`);
20 |
21 | console.log(`
22 | ${chalk.bold('Access URLs:')}${divider}
23 | Localhost: ${chalk.magenta(`http://${host}:${port}`)}
24 | LAN: ${chalk.magenta(`http://${ip.address()}:${port}`)}${divider}
25 | ${chalk.blue(`Press ${chalk.italic('CTRL-C')} to stop`)}
26 | `);
27 | }
28 | };
29 |
30 | module.exports = logger;
31 |
--------------------------------------------------------------------------------
/server/util/port.js:
--------------------------------------------------------------------------------
1 | const argv = require('./argv');
2 |
3 | module.exports = parseInt(argv.port || process.env.PORT || '3000', 10);
4 |
--------------------------------------------------------------------------------