├── .babelrc
├── .eslintrc
├── .gitignore
├── Readme.md
├── client
├── actions
│ └── index.js
├── components
│ ├── Grid
│ │ ├── Body.js
│ │ ├── Cell.js
│ │ ├── Footer.js
│ │ ├── Grid.js
│ │ ├── Header.js
│ │ ├── Row.js
│ │ ├── index.js
│ │ └── style.css
│ └── Header
│ │ └── index.js
├── containers
│ ├── App
│ │ ├── index.js
│ │ └── style.css
│ ├── TransactionForm
│ │ └── index.js
│ └── TransactionSummary
│ │ └── index.js
├── data
│ └── transactions.json
├── index.html
├── index.js
├── reducers
│ ├── defaults.js
│ ├── index.js
│ └── transactions.js
└── store
│ └── index.js
├── package.json
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "react"],
3 | "plugins": ["transform-runtime", "transform-class-properties"]
4 | }
5 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | extends:
2 | eslint-config-airbnb
3 |
4 | parser:
5 | babel-eslint
6 |
7 | settings:
8 | ecmascript: 6
9 |
10 | ecmaFeatures:
11 | jsx: true
12 | modules: true
13 | destructuring: true
14 | classes: true
15 | forOf: true
16 | blockBindings: true
17 | arrowFunctions: true
18 |
19 | env:
20 | browser: true
21 |
22 | rules:
23 | indent: 2
24 | func-style: 0
25 | func-names: 0
26 | comma-dangle: 0
27 | no-console: 0
28 | no-param-reassign: 0
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_STORE
2 | node_modules
3 | static
4 | .module-cache
5 | *.log*
6 |
7 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | 
2 | ### [DEPRECATED] This repository is no longer maintained
3 | > While this project is fully functional, the dependencies are no longer up to date. You are still welcome to explore, learn, and use the code provided here.
4 | >
5 | > Modus is dedicated to supporting the community with innovative ideas, best-practice patterns, and inspiring open source solutions. Check out the latest [Modus Labs](https://labs.moduscreate.com?utm_source=github&utm_medium=readme&utm_campaign=deprecated) projects.
6 |
7 | [](https://labs.moduscreate.com?utm_source=github&utm_medium=readme&utm_campaign=deprecated)
8 |
9 | ---
10 |
11 | # Budgeting - React + Redux Sample App
12 |
13 | Sample app demonstrating the power and simplicity of React, Redux, and Webpack.
14 |
15 | ## Contains
16 |
17 | - [x] [Webpack](https://webpack.github.io)
18 | - [x] [React](https://facebook.github.io/react/)
19 | - [x] [Redux](https://github.com/rackt/redux)
20 | - [x] [Babel](https://babeljs.io/)
21 |
22 | ## Setup
23 |
24 | ```
25 | $ npm install
26 | ```
27 |
28 | ## Running
29 |
30 | ```
31 | $ npm start
32 | ```
33 |
34 | ## Build
35 |
36 | ```
37 | $ npm run build
38 | ```
39 |
40 | # License
41 |
42 | MIT
43 |
--------------------------------------------------------------------------------
/client/actions/index.js:
--------------------------------------------------------------------------------
1 | export const ADD_TRANSACTION = 'ADD_TRANSACTION';
2 | export const DELETE_TRANSACTION = 'DELETE_TRANSACTION';
3 | export const GET_TRANSACTION_GRID_FIELDS = 'GET_TRANSACTION_GRID_FIELDS';
4 | export const REQUEST_SUM = 'REQUEST_SUM';
5 |
6 | function createTransaction(transaction) {
7 | return {
8 | type: ADD_TRANSACTION,
9 | transaction
10 | };
11 | }
12 |
13 | export function deleteTransaction(id) {
14 | return {
15 | type: ADD_TRANSACTION,
16 | id
17 | };
18 | }
19 |
20 | export function requestSum(data) {
21 | return {
22 | type: REQUEST_SUM,
23 | data
24 | };
25 | }
26 |
27 | export function addTransaction(transaction) {
28 | return (dispatch, getState) => {
29 | const addedResult = dispatch(createTransaction(transaction));
30 | dispatch(requestSum(getState().transactions.transactions));
31 | return addedResult;
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/client/components/Grid/Body.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default ({ children })=> {
4 | return (
5 |
{children}
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/Grid/Cell.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | const Cell = ({ text, className, children }, { header })=> {
4 | if (header === true) {
5 | return (
6 | {text} |
7 | );
8 | }
9 |
10 | return (
11 | {text || children} |
12 | );
13 | };
14 |
15 | Cell.contextTypes = {
16 | header: PropTypes.bool
17 | };
18 |
19 | export default Cell;
20 |
--------------------------------------------------------------------------------
/client/components/Grid/Footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default ({children})=> {
4 | return (
5 | {children}
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/Grid/Grid.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import Header from './Header';
3 | import Body from './Body';
4 | import Row from './Row';
5 | import Cell from './Cell';
6 | import './style.css';
7 |
8 | const { string, shape, arrayOf, object, node } = PropTypes;
9 |
10 | function buildRow(fields, row, rowIndex) {
11 | return (
12 |
13 | {
14 | fields.map((field, cellIndex) => | )
19 | }
20 |
21 | );
22 | }
23 |
24 | function buildBody(fields, data) {
25 | return (
26 |
27 | {
28 | data.map((row, index) => buildRow(fields, row, index))
29 | }
30 |
31 | );
32 | }
33 |
34 | export default class Grid extends Component {
35 | static propTypes = {
36 | fields: arrayOf(shape({
37 | name: string,
38 | mapping: string,
39 | className: string
40 | })).isRequired,
41 | data: arrayOf(object),
42 | children: node
43 | };
44 |
45 | render() {
46 | const { fields, data, children } = this.props;
47 | return (
48 |
49 |
50 |
51 | {
52 | fields.map((field, index) => {
53 | return (
54 | |
59 | );
60 | })
61 | }
62 |
63 |
64 | { buildBody(fields, data) }
65 | {children}
66 |
67 | );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/client/components/Grid/Header.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 |
3 | export default class Header extends Component {
4 | static propTypes = {
5 | children: PropTypes.node.isRequired
6 | };
7 |
8 | static childContextTypes = {
9 | header: PropTypes.bool
10 | };
11 |
12 | getChildContext() {
13 | return {
14 | header: true
15 | };
16 | }
17 |
18 | render() {
19 | const { children } = this.props;
20 | return (
21 | {children}
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/client/components/Grid/Row.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default ({children})=> {
4 | return (
5 | {children}
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/Grid/index.js:
--------------------------------------------------------------------------------
1 | import Grid from './Grid';
2 | import Header from './Header';
3 | import Footer from './Footer';
4 | import Body from './Body';
5 | import Row from './Row';
6 | import Cell from './Cell';
7 |
8 | export { Grid };
9 | export { Header };
10 | export { Body };
11 | export { Row };
12 | export { Cell };
13 |
14 | export default Object.assign(Grid, {
15 | Header, Footer, Body, Row, Cell
16 | });
17 |
--------------------------------------------------------------------------------
/client/components/Grid/style.css:
--------------------------------------------------------------------------------
1 | table {
2 | border-collapse: collapse;
3 | padding: 3px;
4 | user-select: none;
5 | -webkit-user-select: none;
6 | cursor: default;
7 | }
8 |
9 | td {
10 | min-width: 150px;
11 | }
12 |
13 | th {
14 | text-align: left;
15 | border-bottom: 1px solid #ececec;
16 | }
17 |
18 | td, th {
19 | padding: 3px;
20 | }
21 |
22 | td.flex {
23 | width: 100%;
24 | }
25 |
26 | td.align-right, th.align-right {
27 | text-align: right;
28 | }
29 |
30 | td:last-child, th.last-child {
31 | background-color: rgba(210,210,210,0.2);
32 | }
33 |
34 | tbody > tr:hover {
35 | background-color: rgba(150,190,190,0.2);
36 | }
37 |
38 | tbody tr:last-child td {
39 | border-bottom: 1px solid #ececec;
40 | }
41 |
42 | tfoot input {
43 | width: 98%;
44 | }
45 |
46 | tfoot {
47 | font-weight: bold;
48 | }
49 |
--------------------------------------------------------------------------------
/client/components/Header/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default ()=> {
4 | return (
5 |
8 | );
9 | };
10 |
--------------------------------------------------------------------------------
/client/containers/App/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { bindActionCreators } from 'redux';
3 | import { connect } from 'react-redux';
4 |
5 | import Header from 'components/Header';
6 | import Grid from 'components/Grid';
7 | import TransactionForm from 'containers/TransactionForm';
8 | import TransactionSummary from 'containers/TransactionSummary';
9 | import * as AppActions from 'actions';
10 | import './style.css';
11 |
12 | class App extends Component {
13 | static propTypes = {
14 | transactions: PropTypes.array,
15 | summary: PropTypes.object,
16 | gridFields: PropTypes.array,
17 | actions: PropTypes.object
18 | };
19 |
20 | componentWillMount() {
21 | const { transactions, actions } = this.props;
22 | actions.requestSum(transactions);
23 | }
24 |
25 | render() {
26 | const {
27 | transactions,
28 | gridFields,
29 | summary,
30 | actions
31 | } = this.props;
32 |
33 | return (
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 | }
44 |
45 | function mapStateToProps(state) {
46 | const { transactions } = state;
47 | return {
48 | transactions: transactions.transactions,
49 | summary: transactions.summary,
50 | gridFields: transactions.transactionsGrid
51 | };
52 | }
53 |
54 | function mapDispatchToProps(dispatch) {
55 | return {
56 | actions: bindActionCreators(AppActions, dispatch)
57 | };
58 | }
59 |
60 | export default connect(
61 | mapStateToProps,
62 | mapDispatchToProps
63 | )(App);
64 |
--------------------------------------------------------------------------------
/client/containers/App/style.css:
--------------------------------------------------------------------------------
1 |
2 | html,
3 | body {
4 | margin: 0;
5 | padding: 0;
6 | }
7 |
8 | button {
9 | margin: 0;
10 | padding: 0;
11 | border: 0;
12 | background: none;
13 | font-size: 100%;
14 | vertical-align: baseline;
15 | font-family: inherit;
16 | font-weight: inherit;
17 | color: inherit;
18 | appearance: none;
19 | font-smoothing: antialiased;
20 | }
21 |
22 | body {
23 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
24 | line-height: 1.4em;
25 | background: #f5f5f5;
26 | color: #4d4d4d;
27 | min-width: 230px;
28 | max-width: 550px;
29 | margin: 0 auto;
30 | -webkit-font-smoothing: antialiased;
31 | -moz-font-smoothing: antialiased;
32 | -ms-font-smoothing: antialiased;
33 | font-smoothing: antialiased;
34 | font-weight: 300;
35 | }
36 |
37 | .viewport {
38 | background: #fff;
39 | margin: 200px 0 40px 0;
40 | position: relative;
41 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
42 | 0 25px 50px 0 rgba(0, 0, 0, 0.1);
43 | }
44 |
45 | .viewport input::-webkit-input-placeholder {
46 | font-style: italic;
47 | font-weight: 300;
48 | color: #e6e6e6;
49 | }
50 |
51 | .viewport input::-moz-placeholder {
52 | font-style: italic;
53 | font-weight: 300;
54 | color: #e6e6e6;
55 | }
56 |
57 | .viewport input::input-placeholder {
58 | font-style: italic;
59 | font-weight: 300;
60 | color: #e6e6e6;
61 | }
62 |
63 | .viewport h1 {
64 | position: absolute;
65 | top: -155px;
66 | width: 100%;
67 | font-size: 100px;
68 | font-weight: 100;
69 | text-align: center;
70 | color: rgba(175, 47, 47, 0.15);
71 | user-select: none;
72 | -webkit-user-select: none;
73 | cursor: default;
74 | -webkit-text-rendering: optimizeLegibility;
75 | -moz-text-rendering: optimizeLegibility;
76 | -ms-text-rendering: optimizeLegibility;
77 | text-rendering: optimizeLegibility;
78 | }
79 |
--------------------------------------------------------------------------------
/client/containers/TransactionForm/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import Grid from 'components/Grid';
3 |
4 | export default class TransactionForm extends Component {
5 | static propTypes = {
6 | action: PropTypes.func
7 | };
8 |
9 | onFieldKeyUp(e) {
10 | if (e.keyCode === 13) {
11 | this.submitForm();
12 | }
13 | }
14 |
15 | submitForm() {
16 | const { valueField, descField } = this.refs;
17 | const { action } = this.props;
18 | const value = parseFloat(valueField.value, 10);
19 | const description = descField.value;
20 |
21 | if (value === 0 || isNaN(value) || description.length === 0) {
22 | return;
23 | }
24 |
25 | action({ value, description });
26 |
27 | valueField.value = descField.value = '';
28 | descField.focus();
29 | }
30 |
31 | render() {
32 | return (
33 |
34 |
35 |
36 |
42 |
43 |
44 |
52 |
53 |
54 |
55 | );
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/client/containers/TransactionSummary/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import Grid from 'components/Grid';
3 |
4 | const { string, shape, arrayOf, object } = PropTypes;
5 |
6 | export default class TransactionSummary extends Component {
7 | static propTypes = {
8 | fields: arrayOf(shape({
9 | mapping: string,
10 | className: string
11 | })).isRequired,
12 | data: object
13 | };
14 |
15 | render() {
16 | const { fields, data } = this.props;
17 | return (
18 |
19 |
20 | {
21 | fields.map((field, index) => {
22 | return (
23 |
28 | );
29 | })
30 | }
31 |
32 |
33 | );
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/client/data/transactions.json:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ModusCreateOrg/budgeting-sample-app/760ffcc2996474753fc3041e7a8783abd79ff22e/client/data/transactions.json
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Budgeting - React + Redux Sample
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/client/index.js:
--------------------------------------------------------------------------------
1 | import { Provider } from 'react-redux';
2 | import ReactDOM from 'react-dom';
3 | import React from 'react';
4 |
5 | import App from 'containers/App';
6 | import store from 'store';
7 |
8 | ReactDOM.render(
9 |
10 |
11 | ,
12 | document.getElementById('root')
13 | );
14 |
--------------------------------------------------------------------------------
/client/reducers/defaults.js:
--------------------------------------------------------------------------------
1 | export const defaultTransactions = [
2 | {
3 | id: 1,
4 | description: 'Trader Joe\'s food',
5 | value: -123.34
6 | },
7 | {
8 | id: 2,
9 | description: 'Gas',
10 | value: -64.73
11 | },
12 | {
13 | id: 3,
14 | description: 'Ebay sale - guitar',
15 | value: 102.00
16 | }
17 | ];
18 | export const defaultTransactionGridFields = [
19 | {
20 | name: 'Description',
21 | className: 'flex',
22 | mapping: 'description'
23 | },
24 | {
25 | name: 'Value',
26 | className: 'align-right',
27 | mapping: 'value'
28 | }
29 | ];
30 |
31 | export const defaultSummary = {
32 | description: 'Balance'
33 | };
34 |
--------------------------------------------------------------------------------
/client/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { routeReducer as routing } from 'react-router-redux';
2 | import { combineReducers } from 'redux';
3 |
4 | import transactions from './transactions';
5 |
6 | /**
7 | * Routing to be implemented
8 | */
9 | export default combineReducers({
10 | routing,
11 | transactions
12 | });
13 |
--------------------------------------------------------------------------------
/client/reducers/transactions.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 |
3 | import {
4 | ADD_TRANSACTION,
5 | DELETE_TRANSACTION,
6 | GET_TRANSACTION_GRID_FIELDS,
7 | REQUEST_SUM,
8 | } from 'actions';
9 |
10 | import {
11 | defaultTransactions,
12 | defaultTransactionGridFields,
13 | defaultSummary
14 | } from './defaults';
15 |
16 | /**
17 | * Add a new transaction.
18 | * This is a helper function for the transactions reducer
19 | * @param {Object} state
20 | * @param {Object} action
21 | */
22 | function addTransaction(state, action) {
23 | const { description, value } = action.transaction;
24 | const newState = [...state, {
25 | id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
26 | description: description,
27 | value: value
28 | }];
29 | return newState;
30 | }
31 |
32 | /**
33 | * Main transactions reducer
34 | * @param {Object} state Current state
35 | * @param {Object} action Dispatched action
36 | * @return {Object} Default state
37 | */
38 | function transactions(state=defaultTransactions, action) {
39 | let newState;
40 | switch (action.type) {
41 | case ADD_TRANSACTION:
42 | return addTransaction(state, action);
43 | case DELETE_TRANSACTION:
44 | newState = state.filter(todo => todo.id !== action.id );
45 | return newState;
46 | default:
47 | return state;
48 | }
49 | }
50 |
51 | /**
52 | * Reserved for future use.
53 | * Intended for dynamic grid column setup
54 | * @param {Object} state Current state
55 | * @param {Object} action Dispatched action
56 | * @return {Object} Default state
57 | */
58 | function transactionsGrid(state=defaultTransactionGridFields, action) {
59 | switch (action.type) {
60 | case GET_TRANSACTION_GRID_FIELDS:
61 | return state;
62 | default:
63 | return state;
64 | }
65 | }
66 |
67 | /**
68 | * Summary calculation
69 | * @param {Object} state Current state
70 | * @param {Object} action Dispatched action
71 | * @return {Object} Default state
72 | */
73 | function summary(state=defaultSummary, action) {
74 | switch (action.type) {
75 | case REQUEST_SUM:
76 | let sum = action.data.reduce((prev, current) => {
77 | return {value: prev.value + current.value };
78 | });
79 |
80 | sum = {value: Math.round(sum.value * 100) / 100};
81 |
82 | // Return ES2015 friendly
83 | // or stage-0 {...state, ...sum}
84 | return Object.assign({}, state, sum);
85 | default:
86 | return state;
87 | }
88 | }
89 |
90 | export default combineReducers({
91 | transactionsGrid,
92 | transactions,
93 | summary
94 | });
95 |
--------------------------------------------------------------------------------
/client/store/index.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux';
2 | import thunk from 'redux-thunk';
3 |
4 | import rootReducer from 'reducers';
5 |
6 | const createStoreWithMiddleware = applyMiddleware(thunk)(createStore);
7 | const store = createStoreWithMiddleware(rootReducer);
8 |
9 | export default store;
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-clean-training",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "Clean Redux setup for training",
6 | "main": "index.js",
7 | "scripts": {
8 | "test": "echo \"Error: no test specified\" && exit 1",
9 | "start": "webpack-dev-server --history-api-fallback --hot --inline --progress --colors --port 3000",
10 | "build": "NODE_ENV=production webpack --progress --colors"
11 | },
12 | "license": "MIT",
13 | "devDependencies": {
14 | "babel-core": "^6.3.26",
15 | "babel-loader": "^6.2.0",
16 | "babel-plugin-transform-class-properties": "^6.16.0",
17 | "babel-plugin-transform-runtime": "^6.3.13",
18 | "babel-preset-es2015": "^6.3.13",
19 | "babel-preset-react": "^6.3.13",
20 | "css-loader": "^0.25.0",
21 | "file-loader": "^0.8.4",
22 | "style-loader": "^0.13.1",
23 | "webpack": "^1.12.2",
24 | "webpack-dev-server": "^1.12.0",
25 | "webpack-hot-middleware": "^2.2.0"
26 | },
27 | "dependencies": {
28 | "babel-runtime": "^6.3.19",
29 | "es6-promise": "^3.0.2",
30 | "isomorphic-fetch": "^2.2.1",
31 | "react": "^0.14.0",
32 | "react-dom": "^0.14.0",
33 | "react-hot-loader": "^1.3.0",
34 | "react-redux": "^4.0.6",
35 | "react-router-redux": "^4.0.6",
36 | "redux": "^3.0.2",
37 | "redux-thunk": "^1.0.3"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const path = require('path');
3 |
4 | const nodeEnv = process.env.NODE_ENV || 'development';
5 | const isProd = nodeEnv === 'production';
6 |
7 | module.exports = {
8 | devtool: isProd ? 'hidden-source-map' : 'cheap-eval-source-map',
9 | context: path.join(__dirname, './client'),
10 | entry: {
11 | jsx: './index.js',
12 | html: './index.html',
13 | vendor: ['react']
14 | },
15 | output: {
16 | path: path.join(__dirname, './static'),
17 | filename: 'bundle.js',
18 | },
19 | module: {
20 | loaders: [
21 | {
22 | test: /\.html$/,
23 | loader: 'file?name=[name].[ext]'
24 | },
25 | {
26 | test: /\.css$/,
27 | loaders: [
28 | 'style-loader',
29 | 'css-loader'
30 | ]
31 | },
32 | {
33 | test: /\.(js|jsx)$/,
34 | exclude: /node_modules/,
35 | loaders: [
36 | 'react-hot',
37 | 'babel-loader'
38 | ]
39 | },
40 | ],
41 | },
42 | resolve: {
43 | extensions: ['', '.js', '.jsx'],
44 | root: [
45 | path.resolve('./client')
46 | ]
47 | },
48 | plugins: [
49 | new webpack.optimize.CommonsChunkPlugin('vendor', 'vendor.bundle.js'),
50 | new webpack.optimize.UglifyJsPlugin({
51 | compress: {
52 | warnings: false
53 | },
54 | sourceMap: false
55 | }),
56 | new webpack.DefinePlugin({
57 | 'process.env': { NODE_ENV: JSON.stringify(nodeEnv) }
58 | })
59 | ],
60 | devServer: {
61 | contentBase: './client',
62 | hot: true
63 | }
64 | };
65 |
--------------------------------------------------------------------------------