├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── Dockerfile ├── README.md ├── dev-server.js ├── package.json ├── scripts └── new_component.sh ├── server.js ├── src ├── actions │ ├── fetchUtils.js │ ├── index.js │ └── tableActions.js ├── app.js ├── components │ ├── Header │ │ ├── Header.js │ │ ├── Header.styl │ │ ├── Navigation.js │ │ ├── Navigation.styl │ │ └── package.json │ ├── NutrientTable │ │ ├── Cells.js │ │ ├── NutrientTable.js │ │ └── package.json │ └── ResponsiveTableWrapper │ │ ├── ResponsiveTableWrapper.js │ │ ├── ResponsiveTableWrapper.styl │ │ └── package.json ├── constants │ └── index.js ├── containers │ ├── AboutPage │ │ ├── AboutPage.js │ │ ├── AboutPage.styl │ │ └── package.json │ ├── App │ │ ├── App.js │ │ ├── App.styl │ │ └── package.json │ ├── NotFoundPage │ │ ├── NotFoundPage.js │ │ └── package.json │ ├── NutrientPage │ │ ├── NutrientPage.js │ │ └── package.json │ └── vars.styl ├── modules │ └── renderers.js ├── reducers │ ├── index.js │ └── tableReducer.js ├── static │ ├── favicon.ico │ └── index.html └── store │ ├── configureStore.dev.js │ ├── configureStore.js │ └── configureStore.prod.js ├── tests ├── reducers │ └── tableReducer.js └── testdata │ └── foodnutrients.json ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "browsers": ["last 2 versions"] 6 | } 7 | }], 8 | "react", 9 | "stage-0" 10 | ], 11 | "plugins": [ 12 | "babel-plugin-add-module-exports", 13 | "babel-plugin-transform-runtime" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "standard", "standard-react" ], 3 | "parser": "babel-eslint", 4 | "env": { 5 | "mocha": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # build and dependencies 17 | node_modules 18 | build 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "7" 5 | - "6" 6 | - "5" 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | # Set application working directory 4 | RUN mkdir /app 5 | WORKDIR /app 6 | 7 | # Install JS app dependencies 8 | COPY package.json package.json 9 | RUN npm install 10 | 11 | # Add files needed to build the app 12 | # Copy the application `src` folder inside the container 13 | ADD webpack.config.js /app 14 | ADD .babelrc /app 15 | ADD .eslintrc /app 16 | ADD server.js /app 17 | ADD src /app/src/ 18 | RUN echo `ls /app` 19 | RUN npm run build 20 | 21 | EXPOSE 4000 22 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Redux Table Example 2 | 3 | [![Build Status](https://travis-ci.org/alyssaq/react-redux-table-example.png?branch=master)](https://travis-ci.org/alyssaq/react-redux-table-example) 4 | 5 | Demo: 6 | 7 | Features: 8 | 9 | * [React](https://facebook.github.io/react)-[Redux](http://redux.js.org) data flow 10 | * Fetch JSON from an API and render into a table 11 | * Filter and sort data in the table 12 | * Routing with [react-router 4+](https://github.com/rackt/react-router) 13 | * Redux middlewares 14 | * Separate reducers and actions 15 | * ES6/ES7 with [babeljs](https://babeljs.io) (stage-0, react) 16 | * [Stylus](http://learnboost.github.io/stylus) 17 | * [Webpack 3+](https://webpack.github.io) dev and production 18 | * Eslint [standard](http://standardjs.com) 19 | * Unit tests with [mocha](https://mochajs.org) + [chai](http://chaijs.com) 20 | 21 | ## Install 22 | ```sh 23 | $ npm install 24 | ``` 25 | 26 | ## Run - Development 27 | ```sh 28 | $ npm run dev # builds and hot reloads on changes 29 | ``` 30 | 31 | ## Run - Production 32 | ```sh 33 | $ npm run build # builds production assets (transpile, minify, etc) 34 | $ npm start # Start express server and serves index.html 35 | ``` 36 | 37 | ## Docker 38 | To run a production version in [docker](https://www.docker.com): 39 | ```sh 40 | $ docker build -t react-redux-example . # Build docker container 41 | $ docker run -d --name react-redux-example -p 4000:4000 react-redux-example # Run docker container 42 | ``` 43 | App will be running at 44 | 45 | ```sh 46 | $ docker stop react-redux-example # Stop container 47 | ``` 48 | 49 | ## Tests 50 | ```sh 51 | $ npm run lint # Runs eslint 52 | $ npm test # Runs mocha 53 | $ npm run test:dev # Run mocha in watch mode 54 | ``` 55 | 56 | ## Thanks 57 | Data from [USDA nutrient API](http://ndb.nal.usda.gov/ndb/doc/apilist/API-NUTRIENT-REPORT.md) 58 | 59 | ## License 60 | [MIT](https://alyssaq.github.io/mit-license) 61 | -------------------------------------------------------------------------------- /dev-server.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const WebpackDevServer = require('webpack-dev-server') 3 | const path = require('path') 4 | const baseConfig = require('./webpack.config.js') 5 | 6 | const host = '127.0.0.1' 7 | const port = 3000 8 | const addr = `http://${host}:${port}` 9 | 10 | const config = Object.assign({}, baseConfig, { 11 | devtool: 'inline-source-map', 12 | entry: [ 13 | `webpack-dev-server/client?${addr}`, 14 | 'webpack/hot/only-dev-server', 15 | ...baseConfig.entry 16 | ], 17 | plugins: [ 18 | new webpack.LoaderOptionsPlugin({ 19 | debug: true 20 | }), 21 | // make the store behave like production (less chatty) if desired 22 | new webpack.DefinePlugin({ 23 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 24 | }), 25 | new webpack.HotModuleReplacementPlugin() 26 | ], 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.js?$/, 31 | exclude: /node_modules/, 32 | use: [ 'react-hot-loader' ], 33 | include: [ path.join(__dirname, 'src') ] 34 | }, 35 | ...baseConfig.module.rules 36 | ] 37 | } 38 | }) 39 | 40 | const server = new WebpackDevServer(webpack(config), { 41 | contentBase: 'src/static/', 42 | stats: config.stats, 43 | publicPath: config.output.publicPath, 44 | hot: false, 45 | historyApiFallback: true 46 | }) 47 | 48 | server.listen(port, host, (err) => { 49 | return err ? console.error(err) 50 | : console.log(`Listening on: ${addr}`) 51 | }) 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-example", 3 | "version": "2.0.0", 4 | "description": "React Redux Example", 5 | "engines": { 6 | "node": ">7.0.0" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/alyssaq/react-redux-table-example" 11 | }, 12 | "license": "MIT", 13 | "scripts": { 14 | "start": "node server.js", 15 | "dev": "node dev-server.js", 16 | "build": "rm -rf build && cp -pr src/static/ build && webpack", 17 | "lint": "eslint ./src ./tests *.js", 18 | "test": "npm run lint && mocha tests --recursive -r babel-core/register", 19 | "test:dev": "mocha tests --recursive -r babel-core/register -w" 20 | }, 21 | "dependencies": { 22 | "autoprefixer": "^7.1.1", 23 | "babel-core": "^6.24.1", 24 | "babel-loader": "^7.0.0", 25 | "babel-plugin-add-module-exports": "^0.2.1", 26 | "babel-plugin-transform-runtime": "^6.23.0", 27 | "babel-preset-env": "^1.4.0", 28 | "babel-preset-react": "^6.24.1", 29 | "babel-preset-stage-0": "^6.24.1", 30 | "express": "^4.14.1", 31 | "fixed-data-table-2": "^0.7.17", 32 | "lodash": "^4.17.4", 33 | "prop-types": "^15.5.8", 34 | "react": "^15.6.1", 35 | "react-dom": "^15.5.4", 36 | "react-redux": "^5.0.4", 37 | "react-router": "^4.1.1", 38 | "react-router-dom": "^4.1.1", 39 | "redux": "^3.7.0", 40 | "redux-thunk": "^2.2.0", 41 | "shrink-ray": "^0.1.3", 42 | "stylus": "^0.54.5" 43 | }, 44 | "devDependencies": { 45 | "babel-eslint": "^7.2.3", 46 | "chai": "^4.0.2", 47 | "css-loader": "^0.28.0", 48 | "eslint": "^4.1.0", 49 | "eslint-config-standard": "^10.2.1", 50 | "eslint-config-standard-react": "^5.0.0", 51 | "eslint-loader": "^1.6.1", 52 | "eslint-plugin-import": "^2.2.0", 53 | "eslint-plugin-node": "^5.0.0", 54 | "eslint-plugin-promise": "^3.4.2", 55 | "eslint-plugin-react": "^7.1.0", 56 | "eslint-plugin-standard": "^3.0.1", 57 | "mocha": "^3.3.0", 58 | "postcss-loader": "^2.0.6", 59 | "react-hot-loader": "^1.3.1", 60 | "redux-logger": "^3.0.6", 61 | "style-loader": "^0.18.2", 62 | "stylus-loader": "^3.0.1", 63 | "url-loader": "^0.5.7", 64 | "webpack": "^3.0.0", 65 | "webpack-dev-server": "^2.5.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /scripts/new_component.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # To use: ./new_component.sh 4 | # Example: ./new_component.sh BaseTable HappyTable 5 | 6 | component_folder=src/components 7 | old=$1 8 | new=$2 9 | 10 | echo "Creating $component_folder/$new" 11 | cp -Ra $component_folder/$1/ $component_folder/$new/ 12 | mv $component_folder/$new/$old.js $component_folder/$new/$new.js 13 | mv $component_folder/$new/$old.styl $component_folder/$new/$new.styl 14 | find $component_folder/$new/ -type f -exec sed -i '' s/$old/$new/g {} + 15 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const path = require('path') 3 | const app = express() 4 | const buildPath = path.join(__dirname, 'build') 5 | 6 | app.use(require('shrink-ray')()) 7 | app.use(express.static(buildPath)) 8 | app.use((req, res, next) => { 9 | if (req.method === 'GET' && req.accepts('html')) { 10 | res.sendFile('index.html', {root: buildPath}, function (err) { 11 | return err && next() 12 | }) 13 | } else next() 14 | }) 15 | 16 | const port = process.env.PORT || 4000 17 | app.listen(port, '0.0.0.0', function () { 18 | console.log('Server listening on port: ' + port) 19 | }) 20 | -------------------------------------------------------------------------------- /src/actions/fetchUtils.js: -------------------------------------------------------------------------------- 1 | import { ACTIONS } from '../constants' 2 | 3 | function handleResponse (response) { 4 | if (response.status >= 200 && response.status < 300) { 5 | return response.json() 6 | } 7 | throw new Error(formatErrorMessage(response)) 8 | } 9 | 10 | function formatErrorMessage (res) { 11 | return `[${res.status}]: ${res.statusText} (${res.url})` 12 | } 13 | 14 | // Error action that is dispatched on failed fetch requests 15 | function errorAction (error) { 16 | return { 17 | type: ACTIONS.SET_ERROR_MESSAGE, 18 | error: true, 19 | errorMessage: error.message 20 | } 21 | } 22 | 23 | // Generic fetchDispatch utility that dispatches 3 actions: 24 | // Request, Receive and Error 25 | // @param {object} opts: 26 | // { 27 | // url: {string} - url to request 28 | // types: { 29 | // request: {string} - constant when fetch begins a request, 30 | // receive: {string} - constant when fetch has successfully received a request 31 | // }, 32 | // onReceived: {func(data)} - function to invoke when request has succeeded. 33 | // It must return a object associated with a successful fetch action. 34 | // First parameter is the json response. By default, data is return in the object 35 | // Default success action: {type: opts.types.receive, data: data} 36 | // } 37 | export default function fetchDispatch (opts) { 38 | return (dispatch) => { 39 | dispatch({ type: opts.types.request }) 40 | 41 | return window.fetch(opts.url, { headers: opts.headers || {} }) 42 | .then(handleResponse) 43 | .then((data) => { // Dispatch the recevied action with type and data 44 | const obj = opts.onReceived ? opts.onReceived(data) : { data } 45 | return dispatch(Object.assign({ type: opts.types.receive }, obj)) 46 | }).catch((error) => dispatch(errorAction(error))) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | import { ACTIONS } from '../constants' 2 | import tableActions from './tableActions' 3 | 4 | function resetErrorMessage () { 5 | return { type: ACTIONS.RESET_ERROR_MESSAGE } 6 | } 7 | 8 | export { 9 | tableActions, 10 | resetErrorMessage 11 | } 12 | -------------------------------------------------------------------------------- /src/actions/tableActions.js: -------------------------------------------------------------------------------- 1 | import CONSTS from '../constants' 2 | import fetchDispatch from './fetchUtils' 3 | 4 | const nutrientSep = '&nutrients=' 5 | const apiProps = { 6 | url: CONSTS.USDA_NUTRIENTS_URL_WITH_APIKEY + 7 | nutrientSep + CONSTS.NUTRIENTS.join(nutrientSep), 8 | types: { 9 | request: CONSTS.ACTIONS.REQUEST_NUTRIENTS_DATA, 10 | receive: CONSTS.ACTIONS.RECEIVE_NUTRIENTS_DATA 11 | } 12 | } 13 | 14 | function shouldFetchData ({table}) { 15 | return (!table.data || !table.isFetching) 16 | } 17 | 18 | function fetchData () { 19 | return (dispatch, getState) => { 20 | if (shouldFetchData(getState())) { 21 | return dispatch(fetchDispatch(apiProps)) 22 | } 23 | } 24 | } 25 | 26 | function filterBy (filterString) { 27 | return { 28 | type: CONSTS.ACTIONS.FILTER_NUTRIENTS_DATA, 29 | filterString 30 | } 31 | } 32 | 33 | function sortBy (sortKey) { 34 | return { 35 | type: CONSTS.ACTIONS.SORT_NUTRIENTS_DATA, 36 | sortKey 37 | } 38 | } 39 | 40 | export default { fetchData, filterBy, sortBy } 41 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { Provider } from 'react-redux' 4 | import App from './containers/App' 5 | import configureStore from './store/configureStore' 6 | 7 | // Define the initial state properties here 8 | const initialAppState = { 9 | table: { 10 | isFetching: false, 11 | allData: [], // stores the unfiltered data 12 | data: [], // stores data to be rendered by component 13 | filterString: '', 14 | sortDesc: false, 15 | sortKey: 'nutrient' 16 | }, 17 | errorMessage: null 18 | } 19 | 20 | const store = configureStore(initialAppState) 21 | 22 | render( 23 | 24 | 25 | , 26 | document.getElementById('app') 27 | ) 28 | -------------------------------------------------------------------------------- /src/components/Header/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './Header.styl' 3 | import Navigation from './Navigation' 4 | import { Link } from 'react-router-dom' 5 | 6 | export default (props) => { 7 | return ( 8 |
9 | 10 | React Redux Example 11 | 12 | 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Header/Header.styl: -------------------------------------------------------------------------------- 1 | $logo-url = url('//cdn4.iconfinder.com/data/icons/eldorado-food-1/40/strawberry-512.png') 2 | 3 | header 4 | display flex 5 | background rgba(255, 255, 255, .95) 6 | top 0 7 | left 0 8 | right 0 9 | height 4em 10 | border-bottom 1px solid rgba(0, 0, 0, .1) 11 | z-index 200 12 | padding .75em 2em 13 | 14 | > strong 15 | flex 1 1 50% 16 | text-overflow ellipsis 17 | white-space nowrap 18 | overflow hidden 19 | 20 | a 21 | font-size 1.75em 22 | background transparent $logo-url no-repeat left top 23 | background-size 1.25em 24 | padding 0.1em 0em 0.1em 1.4em 25 | color #333 !important 26 | letter-spacing -0.05em 27 | opacity .5 28 | font-weight 100 29 | 30 | > nav 31 | flex 1 1 50% 32 | align-self center 33 | text-align right 34 | 35 | /* Portrait */ 36 | @media only screen and (max-width: 640px) 37 | header 38 | padding 0.75em 1em 39 | -------------------------------------------------------------------------------- /src/components/Header/Navigation.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NavLink } from 'react-router-dom' 3 | import './Navigation.styl' 4 | 5 | export default (props) => { 6 | return ( 7 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Header/Navigation.styl: -------------------------------------------------------------------------------- 1 | nav 2 | ul 3 | list-style none 4 | text-align right 5 | padding 0 6 | margin 0 7 | 8 | li 9 | display inline-block 10 | padding 1em 11 | 12 | a:hover 13 | border-bottom #f6f7f8 2px solid 14 | 15 | a.active 16 | border-bottom #f6f7f8 2px solid 17 | color #025984 -------------------------------------------------------------------------------- /src/components/Header/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./Header.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/components/NutrientTable/Cells.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Cell } from 'fixed-data-table-2' 4 | import renderers from '../../modules/renderers' 5 | 6 | // Stateless cell components for Table component 7 | export function SortHeaderCell ({children, sortBy, sortKey, sortDesc, columnKey, ...props}) { 8 | const clickFunc = () => sortBy(columnKey) 9 | 10 | return ( 11 | 12 | 13 | {children} {renderers.renderSortArrow(sortKey, sortDesc, columnKey)} 14 | 15 | 16 | ) 17 | } 18 | 19 | SortHeaderCell.propTypes = { 20 | sortBy: PropTypes.func.isRequired, 21 | sortKey: PropTypes.string.isRequired, 22 | sortDesc: PropTypes.bool.isRequired, 23 | columnKey: PropTypes.string, 24 | children: PropTypes.any 25 | } 26 | 27 | export function DataCell ({data, rowIndex, columnKey, ...props}) { 28 | return {data[rowIndex][columnKey]} 29 | } 30 | 31 | DataCell.propTypes = { 32 | data: PropTypes.array.isRequired, 33 | rowIndex: PropTypes.number, 34 | columnKey: PropTypes.string 35 | } 36 | -------------------------------------------------------------------------------- /src/components/NutrientTable/NutrientTable.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Column } from 'fixed-data-table-2' 4 | import { SortHeaderCell, DataCell } from './Cells' 5 | import ResponsiveTableWrapper from '../ResponsiveTableWrapper' 6 | 7 | class NutrientTable extends React.Component { 8 | componentWillMount () { 9 | this.props.fetchData() 10 | } 11 | 12 | handleFilterStringChange () { 13 | return (e) => { 14 | e.preventDefault() 15 | this.props.filterBy(e.target.value) 16 | } 17 | } 18 | 19 | render () { 20 | const { isFetching, data, filterString, sortBy, sortKey, sortDesc } = this.props 21 | const headerCellProps = { sortBy, sortKey, sortDesc } 22 | 23 | return ( 24 |
25 | 29 |
30 | 31 | {isFetching &&
} 32 | {!isFetching && data.length === 0 && 33 |

No Matching Results :(

} 34 | 35 | 39 | Food } 42 | cell={} 43 | flexGrow={3} 44 | width={100} /> 45 | Nutrient } 48 | cell={} 49 | flexGrow={1} 50 | width={100} /> 51 | Value } 54 | cell={} 55 | flexGrow={0.5} 56 | width={100} /> 57 | Unit } 60 | cell={} 61 | flexGrow={0.1} 62 | width={100} /> 63 | 64 |
65 | ) 66 | } 67 | } 68 | 69 | NutrientTable.propTypes = { 70 | // actions 71 | fetchData: PropTypes.func.isRequired, 72 | sortBy: PropTypes.func.isRequired, 73 | filterBy: PropTypes.func.isRequired, 74 | 75 | // state data 76 | data: PropTypes.array.isRequired, 77 | filterString: PropTypes.string.isRequired, 78 | sortKey: PropTypes.string.isRequired, 79 | sortDesc: PropTypes.bool.isRequired, 80 | isFetching: PropTypes.bool.isRequired 81 | } 82 | 83 | export default NutrientTable 84 | -------------------------------------------------------------------------------- /src/components/NutrientTable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./NutrientTable.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/components/ResponsiveTableWrapper/ResponsiveTableWrapper.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { debounce } from 'lodash' 4 | import { Table } from 'fixed-data-table-2' 5 | import './ResponsiveTableWrapper.styl' 6 | 7 | // Handles all to make it responsive 8 | class ResponsiveTableWrapper extends React.Component { 9 | constructor (props) { 10 | super(props) 11 | this.state = { 12 | tableWidth: 800, 13 | tableHeight: 600 14 | } 15 | } 16 | 17 | handleResize () { 18 | const padding = this.props.padding 19 | const widthOffset = window.innerWidth < 680 20 | ? padding.leftRight / 2 : padding.leftRight 21 | 22 | this.setState({ 23 | tableWidth: window.innerWidth - widthOffset, 24 | tableHeight: window.innerHeight - padding.topBottom 25 | }) 26 | } 27 | 28 | _attachResizeEvent (func) { 29 | const win = window 30 | 31 | if (win.addEventListener) { 32 | win.addEventListener('resize', func, false) 33 | } else if (win.attachEvent) { 34 | win.attachEvent('resize', func) 35 | } else { 36 | win.onresize = func 37 | } 38 | } 39 | 40 | componentDidMount () { 41 | this.handleResize() 42 | this.handleResize = debounce( 43 | this.handleResize, 44 | this.props.refreshRate 45 | ).bind(this) 46 | this._attachResizeEvent(this.handleResize) 47 | } 48 | 49 | componentWillUnmount () { 50 | const win = window 51 | 52 | if (win.detachEventListener) { 53 | win.detachEventListener('resize', this.handleResize, false) 54 | } else if (win.detachEvent) { 55 | win.detachEvent('resize', this.handleResize) 56 | } else { 57 | win.onresize = null 58 | } 59 | } 60 | 61 | render () { 62 | return
66 | } 67 | } 68 | 69 | ResponsiveTableWrapper.propTypes = { 70 | padding: PropTypes.object, 71 | refreshRate: PropTypes.number 72 | } 73 | 74 | ResponsiveTableWrapper.defaultProps = { 75 | refreshRate: 200, // ms 76 | padding: {topBottom: 210, leftRight: 80} 77 | } 78 | 79 | export default ResponsiveTableWrapper 80 | -------------------------------------------------------------------------------- /src/components/ResponsiveTableWrapper/ResponsiveTableWrapper.styl: -------------------------------------------------------------------------------- 1 | h2 2 | text-align center 3 | margin 0 4 | 5 | .filter-input 6 | padding 1em 7 | display flex 8 | width 40% 9 | 10 | .public_fixedDataTableCell_cellContent > a 11 | cursor pointer 12 | 13 | .ScrollbarLayout_main 14 | box-sizing border-box 15 | outline none 16 | overflow hidden 17 | position absolute 18 | transition-duration 250ms 19 | transition-timing-function ease 20 | user-select none 21 | 22 | .ScrollbarLayout_mainVertical 23 | bottom 0 24 | right 0 25 | top 0 26 | transition-property background-color width 27 | width 15px 28 | 29 | .ScrollbarLayout_mainVertical.public_Scrollbar_mainActive, 30 | .ScrollbarLayout_mainVertical:hover 31 | width 17px 32 | 33 | .ScrollbarLayout_mainHorizontal 34 | bottom 0 35 | height 15px 36 | left 0 37 | transition-property background-color height 38 | 39 | .ScrollbarLayout_mainHorizontal.public_Scrollbar_mainActive, 40 | .ScrollbarLayout_mainHorizontal:hover 41 | height 17px 42 | 43 | .ScrollbarLayout_face 44 | left 0 45 | overflow hidden 46 | position absolute 47 | z-index 1 48 | &:after 49 | border-radius 6px 50 | content '' 51 | display block 52 | position absolute 53 | transition background-color 250ms ease 54 | 55 | .ScrollbarLayout_faceHorizontal 56 | bottom 0 57 | left 0 58 | top 0 59 | &:after 60 | bottom 4px 61 | left 0 62 | top 4px 63 | width 100% 64 | 65 | .ScrollbarLayout_faceVertical 66 | left 0 67 | right 0 68 | top 0 69 | &:after 70 | height 100% 71 | left 4px 72 | right 4px 73 | top 0 74 | 75 | .fixedDataTableCellGroupLayout_cellGroup 76 | backface-visibility hidden 77 | left 0 78 | overflow hidden 79 | position absolute 80 | top 0 81 | white-space nowrap 82 | & > .public_fixedDataTableCell_main 83 | display inline-block 84 | vertical-align top 85 | white-space normal 86 | 87 | .fixedDataTableCellGroupLayout_cellGroupWrapper 88 | position absolute 89 | top 0 90 | 91 | .fixedDataTableCellLayout_main 92 | box-sizing border-box 93 | display block 94 | overflow hidden 95 | position absolute 96 | white-space normal 97 | 98 | .fixedDataTableCellLayout_alignRight 99 | text-align right 100 | 101 | .fixedDataTableCellLayout_alignCenter 102 | text-align center 103 | 104 | .fixedDataTableCellLayout_wrap1 105 | display table 106 | 107 | .fixedDataTableCellLayout_wrap2 108 | display table-row 109 | 110 | .fixedDataTableCellLayout_wrap3 111 | display table-cell 112 | vertical-align middle 113 | 114 | .fixedDataTableCellLayout_columnResizerContainer 115 | position absolute 116 | right 0px 117 | width 6px 118 | z-index 1 119 | &:hover 120 | cursor ew-resize 121 | .fixedDataTableCellLayout_columnResizerKnob 122 | visibility visible 123 | 124 | .fixedDataTableCellLayout_columnResizerKnob 125 | position absolute 126 | right 0px 127 | visibility hidden 128 | width 4px 129 | 130 | .fixedDataTableColumnResizerLineLayout_mouseArea 131 | cursor ew-resize 132 | position absolute 133 | right -5px 134 | width 12px 135 | 136 | .fixedDataTableColumnResizerLineLayout_main 137 | box-sizing border-box 138 | position absolute 139 | z-index 10 140 | 141 | .fixedDataTableColumnResizerLineLayout_hiddenElem 142 | display none !important 143 | 144 | .fixedDataTableLayout_main 145 | overflow hidden 146 | position relative 147 | margin 0 auto 148 | 149 | .fixedDataTableLayout_topShadow, 150 | .fixedDataTableLayout_bottomShadow 151 | height 4px 152 | left 0 153 | position absolute 154 | right 0 155 | z-index 1 156 | 157 | .fixedDataTableLayout_bottomShadow 158 | margin-top -4px 159 | 160 | .fixedDataTableLayout_rowsContainer 161 | overflow hidden 162 | position relative 163 | 164 | .fixedDataTableLayout_horizontalScrollbar 165 | bottom 0 166 | position absolute 167 | 168 | .fixedDataTableRowLayout_main 169 | box-sizing border-box 170 | overflow hidden 171 | position absolute 172 | top 0 173 | 174 | .fixedDataTableRowLayout_body 175 | left 0 176 | position absolute 177 | top 0 178 | 179 | .fixedDataTableRowLayout_fixedColumnsDivider 180 | -webkit-backface-visibility hidden 181 | backface-visibility hidden 182 | border-left-style solid 183 | border-left-width 1px 184 | left 0 185 | position absolute 186 | top 0 187 | width 0 188 | 189 | .fixedDataTableRowLayout_columnsShadow 190 | width 4px 191 | 192 | .fixedDataTableRowLayout_rowWrapper 193 | position absolute 194 | top 0 195 | 196 | .public_Scrollbar_main.public_Scrollbar_mainActive, 197 | .public_Scrollbar_main:hover 198 | background-color rgba(255, 255, 255, 0.8) 199 | background-color rgba(255, 255, 255, 0.8) 200 | 201 | .public_Scrollbar_mainOpaque, 202 | .public_Scrollbar_mainOpaque.public_Scrollbar_mainActive, 203 | .public_Scrollbar_mainOpaque:hover 204 | background-color #fff 205 | 206 | .public_Scrollbar_face 207 | &:after 208 | background-color #c2c2c2 209 | 210 | .public_Scrollbar_main:hover .public_Scrollbar_face:after, 211 | .public_Scrollbar_mainActive .public_Scrollbar_face:after, 212 | .public_Scrollbar_faceActive:after 213 | background-color #7d7d7d 214 | 215 | .public_fixedDataTable_header 216 | .public_fixedDataTableCell_main 217 | font-weight bold 218 | 219 | .public_fixedDataTable_header, 220 | .public_fixedDataTable_header .public_fixedDataTableCell_main 221 | background-color #f6f7f8 222 | background-image linear-gradient(#fdfdfd, #efefef) 223 | 224 | .public_fixedDataTable_footer 225 | .public_fixedDataTableCell_main 226 | background-color #f6f7f8 227 | border-color #d3d3d3 228 | 229 | .public_fixedDataTable_horizontalScrollbar 230 | .public_Scrollbar_mainHorizontal 231 | background-color #fff 232 | 233 | .public_fixedDataTableCell_main 234 | background-color #fff 235 | border-color #d3d3d3 236 | 237 | .public_fixedDataTableCell_highlighted 238 | background-color #f4f4f4 239 | 240 | .public_fixedDataTableCell_cellContent 241 | padding 6px 242 | 243 | .public_fixedDataTableCell_columnResizerKnob 244 | background-color #0284ff 245 | 246 | .public_fixedDataTableColumnResizerLine_main 247 | border-color #0284ff 248 | 249 | .public_fixedDataTableRow_main 250 | background-color #fff 251 | 252 | .public_fixedDataTableRow_highlighted, 253 | .public_fixedDataTableRow_highlighted .public_fixedDataTableCell_main 254 | background-color #f6f7f8 255 | 256 | .public_fixedDataTableRow_fixedColumnsDivider 257 | border-color #d3d3d3 258 | -------------------------------------------------------------------------------- /src/components/ResponsiveTableWrapper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./ResponsiveTableWrapper.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | function stringsToObject (actions) { 2 | return actions.trim().split(/\s+/).reduce((obj, action) => { 3 | obj[action] = action 4 | return obj 5 | }, {}) 6 | } 7 | 8 | export default { 9 | // nutrient ids from SR28 docs 10 | // http://www.ars.usda.gov/sp2UserFiles/Place/80400525/Data/SR/SR28/sr28_doc.pdf 11 | NUTRIENTS: [208, 205, 203, 204, 269, 291, 303], 12 | USDA_NUTRIENTS_URL_WITH_APIKEY: 'http://api.nal.usda.gov/ndb/nutrients?' + 13 | 'api_key=uFKMsZENr1ZUZEIDu5CYzA8UeVERm57BEZj2jBK1&max=1500', 14 | 15 | ACTIONS: stringsToObject(` 16 | REQUEST_NUTRIENTS_DATA 17 | RECEIVE_NUTRIENTS_DATA 18 | FILTER_NUTRIENTS_DATA 19 | SORT_NUTRIENTS_DATA 20 | 21 | SET_ERROR_MESSAGE 22 | RESET_ERROR_MESSAGE 23 | `) 24 | } 25 | -------------------------------------------------------------------------------- /src/containers/AboutPage/AboutPage.js: -------------------------------------------------------------------------------- 1 | import './AboutPage.styl' 2 | import React from 'react' 3 | 4 | const AboutPage = () => { 5 | return ( 6 |
7 |

About

8 | This example app serves as a boilerplate for my recurring use case:
9 |
    10 |
  1. Request data from multiple APIs
  2. 11 |
  3. Transform/merge the data
  4. 12 |
  5. Display and interact via the UI
  6. 13 |
14 |

15 | With the app development flow out-of-the-way, 16 | I can focus on the data analytics and D3 visualisations. 17 |

18 |

19 | The learning never stops so any feedback, comments, 20 | criticisms are greatly welcomed! 21 |

22 |

Source code at:   23 | 24 | 26 | /alyssaq/react-redux-table-example 27 | 28 |

29 |
30 | ) 31 | } 32 | 33 | export default AboutPage 34 | -------------------------------------------------------------------------------- /src/containers/AboutPage/AboutPage.styl: -------------------------------------------------------------------------------- 1 | @require('../vars.styl') 2 | 3 | .about 4 | margin 0 auto 5 | width 50% 6 | 7 | h1 8 | text-align center 9 | 10 | .github-logo 11 | width 22px 12 | vertical-align bottom 13 | 14 | @media (max-width: 800px) 15 | .about 16 | width 85% -------------------------------------------------------------------------------- /src/containers/AboutPage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./AboutPage.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/containers/App/App.js: -------------------------------------------------------------------------------- 1 | import './App.styl' 2 | import React from 'react' 3 | import PropTypes from 'prop-types' 4 | import { connect } from 'react-redux' 5 | import { BrowserRouter, Route, Switch } from 'react-router-dom' 6 | import { resetErrorMessage } from '../../actions' 7 | import Header from '../../components/Header' 8 | import NutrientPage from '../NutrientPage' 9 | import AboutPage from '../AboutPage' 10 | import NotFoundPage from '../NotFoundPage' 11 | 12 | class App extends React.Component { 13 | handleDismissClick () { 14 | return (e) => { 15 | e.preventDefault() 16 | this.props.resetErrorMessage() 17 | } 18 | } 19 | 20 | renderErrorMessage () { 21 | const { errorMessage } = this.props 22 | if (!errorMessage) return null 23 | 24 | return ( 25 |

26 | {errorMessage} 27 | 28 | ✘ 29 | 30 |

31 | ) 32 | } 33 | 34 | render () { 35 | return 36 |
37 |
38 | {this.renderErrorMessage()} 39 | 40 |
41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 |
49 |
50 | } 51 | } 52 | 53 | App.propTypes = { 54 | errorMessage: PropTypes.any, 55 | resetErrorMessage: PropTypes.func 56 | } 57 | 58 | export default connect( 59 | (state) => ({ errorMessage: state.errorMessage }), 60 | { resetErrorMessage: resetErrorMessage } 61 | )(App) 62 | -------------------------------------------------------------------------------- /src/containers/App/App.styl: -------------------------------------------------------------------------------- 1 | @require('../vars.styl') 2 | 3 | html 4 | background #fff 5 | height 100% 6 | 7 | body 8 | font-size 16px 9 | font-weight 300 10 | font-family 'Myriad Set', 'Lucida Grande', 'Lucida Sans Unicode', 'Helvetica Neue', Helvetica, Arial, sans-serif 11 | color $font-color 12 | line-height 1.333333 13 | height 100% 14 | margin 0 15 | 16 | .center 17 | text-align center 18 | 19 | main 20 | padding 1.5em 1.5em 0em 1.5em 21 | * 22 | &, 23 | &:after, 24 | &:before 25 | box-sizing border-box 26 | 27 | a 28 | &, 29 | &:visited, 30 | &:hover 31 | text-decoration none 32 | color $link-color 33 | 34 | input, textarea, select, a, button, :focus 35 | outline none 36 | outline-style none 37 | box-shadow none 38 | border-color transparent 39 | 40 | .error 41 | color $error-color 42 | background-color $error-background-color 43 | padding 10px 44 | 45 | .close 46 | float right 47 | font-size xx-large 48 | margin-top -10px 49 | cursor pointer 50 | 51 | 52 | .filter-input 53 | padding 0.25em 54 | margin 0 auto 55 | font-size 14px 56 | border solid 1px $light-border-color 57 | border-bottom solid 2px $light-border-color 58 | transition border 0.3s 59 | 60 | .filter-input:focus 61 | border solid 1px $light-border-color 62 | border-bottom solid 2px $dark-border-color 63 | 64 | /* Jumping box loader */ 65 | .loader-box, .boxMsg 66 | position absolute 67 | left 50% 68 | top 50% 69 | margin -50px 70 | 71 | .loader-box 72 | width 50px 73 | height 50px 74 | &:before 75 | content '' 76 | width 50px 77 | height 5px 78 | background #000 79 | opacity 0.1 80 | position absolute 81 | top 59px 82 | left 0 83 | border-radius 50% 84 | animation shadow .5s linear infinite 85 | 86 | &:after 87 | content '' 88 | width 50px 89 | height 50px 90 | background #1a3668 91 | animation animate .5s linear infinite 92 | position absolute 93 | top 0 94 | left 0 95 | border-radius 3px 96 | 97 | @keyframes animate 98 | 17% 99 | border-bottom-right-radius 3px 100 | 25% 101 | transform translateY(9px) rotate(22.5deg) 102 | 50% 103 | transform translateY(18px) scale(1, .9) rotate(45deg) 104 | border-bottom-right-radius 40px 105 | 75% 106 | transform translateY(9px) rotate(67.5deg) 107 | 100% 108 | transform translateY(0) rotate(90deg) 109 | 110 | @keyframes shadow 111 | 0%, 100% 112 | transform scale(1, 1) 113 | 50% 114 | transform scale(1.2, 1) 115 | 116 | @keyframes spinner 117 | 0% 118 | transform rotate(0deg) 119 | 100% 120 | transform rotate(360deg) 121 | -------------------------------------------------------------------------------- /src/containers/App/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./App.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/containers/NotFoundPage/NotFoundPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const NotFoundPage = (props) => { 4 | return ( 5 |
6 |

Page Not Found

7 |

Sorry, but the page at {window.location.pathname} does not exist.

8 |
9 | ) 10 | } 11 | 12 | export default NotFoundPage 13 | -------------------------------------------------------------------------------- /src/containers/NotFoundPage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./NotFoundPage.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/containers/NutrientPage/NutrientPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import NutrientTable from '../../components/NutrientTable' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { tableActions as actions } from '../../actions' 6 | 7 | const NutrientPage = (props) => { 8 | return ( 9 |
10 |

Food Nutrients List

11 | 12 |
13 | ) 14 | } 15 | 16 | const mapStateToProps = ({table}) => table 17 | const mapDispatchToProps = (dispatch) => bindActionCreators(actions, dispatch) 18 | export default connect(mapStateToProps, mapDispatchToProps)(NutrientPage) 19 | -------------------------------------------------------------------------------- /src/containers/NutrientPage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./NutrientPage.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/containers/vars.styl: -------------------------------------------------------------------------------- 1 | $font-color = #333 2 | $link-color = #08c 3 | 4 | $light-border-color = #c9c9c9 5 | $dark-border-color = #969696 6 | 7 | $error-background-color = #FFBABA 8 | $error-color = #D8000C 9 | -------------------------------------------------------------------------------- /src/modules/renderers.js: -------------------------------------------------------------------------------- 1 | class Renderers { 2 | renderSortArrow (sortKey, sortDesc, sortId) { 3 | return sortKey === sortId ? (sortDesc ? '↓' : '↑') : '' 4 | } 5 | 6 | renderDp (num) { 7 | return num ? parseFloat(num).toFixed(2) : 0.0 8 | } 9 | 10 | renderPercent (num) { 11 | const percent = (num * 100).toFixed(0) 12 | return percent > 0 ? percent + '%' : percent + '%' 13 | } 14 | } 15 | 16 | export default new Renderers() 17 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import CONSTS from '../constants' 2 | import { combineReducers } from 'redux' 3 | import table from './tableReducer' 4 | 5 | // Updates error message to notify about the failed fetches. 6 | function errorMessage (state = {}, action) { 7 | const { type, error } = action 8 | 9 | if (type === CONSTS.ACTIONS.RESET_ERROR_MESSAGE) { 10 | return null 11 | } else if (error) { 12 | return action.errorMessage 13 | } 14 | 15 | return state 16 | } 17 | 18 | const rootReducer = combineReducers({ 19 | table, 20 | errorMessage 21 | }) 22 | 23 | export default rootReducer 24 | -------------------------------------------------------------------------------- /src/reducers/tableReducer.js: -------------------------------------------------------------------------------- 1 | import { ACTIONS } from '../constants' 2 | 3 | export function listFoodWithNutrients (data) { 4 | const foods = data.report.foods 5 | 6 | return foods.reduce((arr, food) => { 7 | food.nutrients.forEach((nutrient) => { 8 | nutrient.food = food.name 9 | }) 10 | return arr.concat(food.nutrients) 11 | }, []) 12 | } 13 | 14 | export function objectContains (str) { 15 | return (obj) => { 16 | return (obj.food + obj.nutrient + obj.value + obj.unit).toLowerCase().includes(str) 17 | } 18 | } 19 | 20 | export function filter (data, filterString) { 21 | return filterString !== '' 22 | ? data.filter(objectContains(filterString)) 23 | : data 24 | } 25 | 26 | export function sort (data, sortKey, sortDesc) { 27 | const multiplier = sortDesc ? -1 : 1 28 | return data.sort((a, b) => { 29 | const aVal = a[sortKey] || 0 30 | const bVal = b[sortKey] || 0 31 | return aVal > bVal ? multiplier : (aVal < bVal ? -multiplier : 0) 32 | }) 33 | } 34 | 35 | function handleTableActions (state, action) { 36 | switch (action.type) { 37 | case ACTIONS.REQUEST_NUTRIENTS_DATA: 38 | return { isFetching: true } 39 | case ACTIONS.RECEIVE_NUTRIENTS_DATA: 40 | const allData = sort(listFoodWithNutrients(action.data), state.sortKey, state.sortDesc) 41 | return { 42 | isFetching: false, 43 | allData, 44 | data: filter(allData, state.filterString) 45 | } 46 | case ACTIONS.FILTER_NUTRIENTS_DATA: 47 | return { 48 | filterString: action.filterString.toLowerCase(), 49 | data: filter(state.allData, action.filterString) 50 | } 51 | case ACTIONS.SORT_NUTRIENTS_DATA: 52 | const sortKey = action.sortKey 53 | const sortDesc = state.sortKey === action.sortKey ? !state.sortDesc : false 54 | const sorted = sort(state.allData, sortKey, sortDesc) 55 | 56 | return { 57 | sortKey, 58 | sortDesc, 59 | allData: sorted, 60 | data: filter(sorted, state.filterString) 61 | } 62 | default: 63 | return state 64 | } 65 | } 66 | 67 | function tableReducer (state = {}, action) { 68 | return Object.assign({}, state, handleTableActions(state, action)) 69 | } 70 | 71 | export default tableReducer 72 | -------------------------------------------------------------------------------- /src/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alyssaq/react-redux-table-example/31829547fbbf8ff2ad75afc40cd9b7bdf82f065e/src/static/favicon.ico -------------------------------------------------------------------------------- /src/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Redux Example 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/store/configureStore.dev.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import thunk from 'redux-thunk' 3 | import logger from 'redux-logger' 4 | import rootReducer from '../reducers' 5 | 6 | const createStoreWithMiddleware = applyMiddleware(thunk, logger)(createStore) 7 | export default function configureStore (initialState) { 8 | return createStoreWithMiddleware(rootReducer, initialState) 9 | } 10 | -------------------------------------------------------------------------------- /src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | module.exports = require('./configureStore.prod') 3 | } else { 4 | module.exports = require('./configureStore.dev') 5 | } 6 | -------------------------------------------------------------------------------- /src/store/configureStore.prod.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import thunk from 'redux-thunk' 3 | import rootReducer from '../reducers' 4 | 5 | const createStoreWithMiddleware = applyMiddleware(thunk)(createStore) 6 | export default function configureStore (initialState) { 7 | return createStoreWithMiddleware(rootReducer, initialState) 8 | } 9 | -------------------------------------------------------------------------------- /tests/reducers/tableReducer.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import foodNutrients from '../testdata/foodnutrients.json' 3 | import { 4 | listFoodWithNutrients, 5 | objectContains, 6 | filter, 7 | sort 8 | } from '../../src/reducers/tableReducer' 9 | 10 | describe('tableReducer.listFoodWithNutrients', () => { 11 | it('Lists food with nutrients from nested data', () => { 12 | assert.isObject(foodNutrients) 13 | const result = listFoodWithNutrients(foodNutrients) 14 | assert.isArray(result) 15 | assert.lengthOf(result, 37) 16 | 17 | result.forEach((row) => { 18 | assert.hasAllKeys(row, [ 19 | 'food', 20 | 'nutrient_id', 21 | 'nutrient', 22 | 'unit', 23 | 'value', 24 | 'gm' 25 | ]) 26 | }) 27 | }) 28 | }) 29 | 30 | describe('tableReducer.objectContains', () => { 31 | it('Checks whether any object contains given string', () => { 32 | const input = {food: 'berry', nutrient: 'carb', value: 22} 33 | const testData = [ 34 | { 35 | filterString: 'erry', 36 | expected: true 37 | }, 38 | { 39 | filterString: 'carb', 40 | expected: true 41 | }, 42 | { 43 | filterString: '22', 44 | expected: true 45 | }, 46 | { 47 | filterString: 'oops', 48 | expected: false 49 | } 50 | ] 51 | 52 | testData.forEach((t) => { 53 | assert.equal(objectContains(t.filterString)(input), t.expected) 54 | }) 55 | }) 56 | }) 57 | 58 | describe('tableReducer.filter', () => { 59 | const input = listFoodWithNutrients(foodNutrients) 60 | 61 | it('Does nothing with empty filter string', () => { 62 | const result = filter(input, '') 63 | assert.deepEqual(input, result) 64 | }) 65 | 66 | it('Filters objects with the given string', () => { 67 | const result = filter(input, 'wine') 68 | assert.lengthOf(result, 3) 69 | }) 70 | 71 | it('Filters objects with the given number in string', () => { 72 | const result = filter(input, '39') 73 | assert.lengthOf(result, 1) 74 | assert.include(result[0].food, 'sake') 75 | }) 76 | }) 77 | 78 | describe('tableReducer.sort', () => { 79 | const input = listFoodWithNutrients(foodNutrients) 80 | 81 | it('Sorts by nutrient and ascending order', () => { 82 | const result = sort(input, 'nutrient', false) 83 | assert.include(result[0], {food: 'Beef, cured, dried', nutrient: 'Carbohydrate'}) 84 | assert.include( 85 | result[result.length - 1], 86 | {food: 'Alcoholic beverage, wine, dessert, sweet', nutrient: 'Energy'} 87 | ) 88 | }) 89 | 90 | it('Sorts by nutrient and descending order', () => { 91 | const result = sort(input, 'nutrient', true) 92 | assert.include(result[0], {food: 'Acerola juice, raw', nutrient: 'Energy'}) 93 | assert.include( 94 | result[result.length - 1], 95 | {food: 'Beef, cured, dried', nutrient: 'Carbohydrate'} 96 | ) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /tests/testdata/foodnutrients.json: -------------------------------------------------------------------------------- 1 | { 2 | "report": { 3 | "sr": "28", 4 | "groups": "All groups", 5 | "subset": "All foods", 6 | "end": 150, 7 | "start": 0, 8 | "total": 8490, 9 | "foods": [ 10 | { 11 | "ndbno": "09427", 12 | "name": "Abiyuch, raw", 13 | "weight": 114.0, 14 | "measure": "0.5 cup", 15 | "nutrients": [ 16 | { 17 | "nutrient_id": "208", 18 | "nutrient": "Energy", 19 | "unit": "kcal", 20 | "value": "79", 21 | "gm": 69.0 22 | } 23 | ] 24 | }, 25 | { 26 | "ndbno": "09002", 27 | "name": "Acerola juice, raw", 28 | "weight": 242.0, 29 | "measure": "1.0 cup", 30 | "nutrients": [ 31 | { 32 | "nutrient_id": "208", 33 | "nutrient": "Energy", 34 | "unit": "kcal", 35 | "value": "56", 36 | "gm": 23.0 37 | } 38 | ] 39 | }, 40 | { 41 | "ndbno": "09001", 42 | "name": "Acerola, (west indian cherry), raw", 43 | "weight": 98.0, 44 | "measure": "1.0 cup", 45 | "nutrients": [ 46 | { 47 | "nutrient_id": "208", 48 | "nutrient": "Energy", 49 | "unit": "kcal", 50 | "value": "31", 51 | "gm": 32.0 52 | } 53 | ] 54 | }, 55 | { 56 | "ndbno": "14006", 57 | "name": "Alcoholic beverage, beer, light", 58 | "weight": 29.5, 59 | "measure": "1.0 fl oz", 60 | "nutrients": [ 61 | { 62 | "nutrient_id": "208", 63 | "nutrient": "Energy", 64 | "unit": "kcal", 65 | "value": "9", 66 | "gm": 29.0 67 | } 68 | ] 69 | }, 70 | { 71 | "ndbno": "14007", 72 | "name": "Alcoholic beverage, beer, light, BUD LIGHT", 73 | "weight": 29.5, 74 | "measure": "1.0 fl oz", 75 | "nutrients": [ 76 | { 77 | "nutrient_id": "208", 78 | "nutrient": "Energy", 79 | "unit": "kcal", 80 | "value": "9", 81 | "gm": 29.0 82 | } 83 | ] 84 | }, 85 | { 86 | "ndbno": "14005", 87 | "name": "Alcoholic beverage, beer, light, BUDWEISER SELECT", 88 | "weight": 29.5, 89 | "measure": "1.0 fl oz", 90 | "nutrients": [ 91 | { 92 | "nutrient_id": "208", 93 | "nutrient": "Energy", 94 | "unit": "kcal", 95 | "value": "8", 96 | "gm": 28.0 97 | } 98 | ] 99 | }, 100 | { 101 | "ndbno": "14248", 102 | "name": "Alcoholic beverage, beer, light, higher alcohol", 103 | "weight": 356.0, 104 | "measure": "12.0 fl oz", 105 | "nutrients": [ 106 | { 107 | "nutrient_id": "208", 108 | "nutrient": "Energy", 109 | "unit": "kcal", 110 | "value": "164", 111 | "gm": 46.0 112 | } 113 | ] 114 | }, 115 | { 116 | "ndbno": "14013", 117 | "name": "Alcoholic beverage, beer, light, low carb", 118 | "weight": 29.5, 119 | "measure": "1.0 fl oz", 120 | "nutrients": [ 121 | { 122 | "nutrient_id": "208", 123 | "nutrient": "Energy", 124 | "unit": "kcal", 125 | "value": "8", 126 | "gm": 27.0 127 | } 128 | ] 129 | }, 130 | { 131 | "ndbno": "14003", 132 | "name": "Alcoholic beverage, beer, regular, all", 133 | "weight": 29.7, 134 | "measure": "1.0 fl oz", 135 | "nutrients": [ 136 | { 137 | "nutrient_id": "208", 138 | "nutrient": "Energy", 139 | "unit": "kcal", 140 | "value": "13", 141 | "gm": 43.0 142 | } 143 | ] 144 | }, 145 | { 146 | "ndbno": "14004", 147 | "name": "Alcoholic beverage, beer, regular, BUDWEISER", 148 | "weight": 29.8, 149 | "measure": "1.0 fl oz", 150 | "nutrients": [ 151 | { 152 | "nutrient_id": "208", 153 | "nutrient": "Energy", 154 | "unit": "kcal", 155 | "value": "12", 156 | "gm": 41.0 157 | } 158 | ] 159 | }, 160 | { 161 | "ndbno": "14034", 162 | "name": "Alcoholic beverage, creme de menthe, 72 proof", 163 | "weight": 33.6, 164 | "measure": "1.0 fl oz", 165 | "nutrients": [ 166 | { 167 | "nutrient_id": "208", 168 | "nutrient": "Energy", 169 | "unit": "kcal", 170 | "value": "125", 171 | "gm": 371.0 172 | } 173 | ] 174 | }, 175 | { 176 | "ndbno": "14009", 177 | "name": "Alcoholic beverage, daiquiri, canned", 178 | "weight": 30.5, 179 | "measure": "1.0 fl oz", 180 | "nutrients": [ 181 | { 182 | "nutrient_id": "208", 183 | "nutrient": "Energy", 184 | "unit": "kcal", 185 | "value": "38", 186 | "gm": 125.0 187 | } 188 | ] 189 | }, 190 | { 191 | "ndbno": "14010", 192 | "name": "Alcoholic beverage, daiquiri, prepared-from-recipe", 193 | "weight": 30.2, 194 | "measure": "1.0 fl oz", 195 | "nutrients": [ 196 | { 197 | "nutrient_id": "208", 198 | "nutrient": "Energy", 199 | "unit": "kcal", 200 | "value": "56", 201 | "gm": 186.0 202 | } 203 | ] 204 | }, 205 | { 206 | "ndbno": "14533", 207 | "name": "Alcoholic beverage, distilled, all (gin, rum, vodka, whiskey) 100 proof", 208 | "weight": 27.8, 209 | "measure": "1.0 fl oz", 210 | "nutrients": [ 211 | { 212 | "nutrient_id": "208", 213 | "nutrient": "Energy", 214 | "unit": "kcal", 215 | "value": "82", 216 | "gm": 295.0 217 | } 218 | ] 219 | }, 220 | { 221 | "ndbno": "14037", 222 | "name": "Alcoholic beverage, distilled, all (gin, rum, vodka, whiskey) 80 proof", 223 | "weight": 27.8, 224 | "measure": "1.0 fl oz", 225 | "nutrients": [ 226 | { 227 | "nutrient_id": "208", 228 | "nutrient": "Energy", 229 | "unit": "kcal", 230 | "value": "64", 231 | "gm": 231.0 232 | } 233 | ] 234 | }, 235 | { 236 | "ndbno": "14550", 237 | "name": "Alcoholic beverage, distilled, all (gin, rum, vodka, whiskey) 86 proof", 238 | "weight": 27.8, 239 | "measure": "1.0 fl oz", 240 | "nutrients": [ 241 | { 242 | "nutrient_id": "208", 243 | "nutrient": "Energy", 244 | "unit": "kcal", 245 | "value": "70", 246 | "gm": 250.0 247 | } 248 | ] 249 | }, 250 | { 251 | "ndbno": "14551", 252 | "name": "Alcoholic beverage, distilled, all (gin, rum, vodka, whiskey) 90 proof", 253 | "weight": 27.8, 254 | "measure": "1.0 fl oz", 255 | "nutrients": [ 256 | { 257 | "nutrient_id": "208", 258 | "nutrient": "Energy", 259 | "unit": "kcal", 260 | "value": "73", 261 | "gm": 263.0 262 | } 263 | ] 264 | }, 265 | { 266 | "ndbno": "14532", 267 | "name": "Alcoholic beverage, distilled, all (gin, rum, vodka, whiskey) 94 proof", 268 | "weight": 27.8, 269 | "measure": "1.0 fl oz", 270 | "nutrients": [ 271 | { 272 | "nutrient_id": "208", 273 | "nutrient": "Energy", 274 | "unit": "kcal", 275 | "value": "76", 276 | "gm": 275.0 277 | } 278 | ] 279 | }, 280 | { 281 | "ndbno": "14049", 282 | "name": "Alcoholic beverage, distilled, gin, 90 proof", 283 | "weight": 27.8, 284 | "measure": "1.0 fl oz", 285 | "nutrients": [ 286 | { 287 | "nutrient_id": "208", 288 | "nutrient": "Energy", 289 | "unit": "kcal", 290 | "value": "73", 291 | "gm": 263.0 292 | } 293 | ] 294 | }, 295 | { 296 | "ndbno": "14050", 297 | "name": "Alcoholic beverage, distilled, rum, 80 proof", 298 | "weight": 27.8, 299 | "measure": "1.0 fl oz", 300 | "nutrients": [ 301 | { 302 | "nutrient_id": "208", 303 | "nutrient": "Energy", 304 | "unit": "kcal", 305 | "value": "64", 306 | "gm": 231.0 307 | } 308 | ] 309 | }, 310 | { 311 | "ndbno": "14051", 312 | "name": "Beef, cured, dried", 313 | "nutrients": [ 314 | { 315 | "nutrient_id": "208", 316 | "nutrient": "Carbohydrate", 317 | "unit": "g", 318 | "value": "0.77", 319 | "gm": 231.0 320 | } 321 | ] 322 | }, 323 | { 324 | "ndbno": "14052", 325 | "name": "Alcoholic beverage, distilled, whiskey, 86 proof", 326 | "weight": 27.8, 327 | "measure": "1.0 fl oz", 328 | "nutrients": [ 329 | { 330 | "nutrient_id": "208", 331 | "nutrient": "Energy", 332 | "unit": "kcal", 333 | "value": "70", 334 | "gm": 250.0 335 | } 336 | ] 337 | }, 338 | { 339 | "ndbno": "14415", 340 | "name": "Alcoholic beverage, liqueur, coffee with cream, 34 proof", 341 | "weight": 31.1, 342 | "measure": "1.0 fl oz", 343 | "nutrients": [ 344 | { 345 | "nutrient_id": "208", 346 | "nutrient": "Energy", 347 | "unit": "kcal", 348 | "value": "102", 349 | "gm": 327.0 350 | } 351 | ] 352 | }, 353 | { 354 | "ndbno": "14414", 355 | "name": "Alcoholic beverage, liqueur, coffee, 53 proof", 356 | "weight": 34.8, 357 | "measure": "1.0 fl oz", 358 | "nutrients": [ 359 | { 360 | "nutrient_id": "208", 361 | "nutrient": "Energy", 362 | "unit": "kcal", 363 | "value": "117", 364 | "gm": 336.0 365 | } 366 | ] 367 | }, 368 | { 369 | "ndbno": "14534", 370 | "name": "Alcoholic beverage, liqueur, coffee, 63 proof", 371 | "weight": 34.8, 372 | "measure": "1.0 fl oz", 373 | "nutrients": [ 374 | { 375 | "nutrient_id": "208", 376 | "nutrient": "Energy", 377 | "unit": "kcal", 378 | "value": "107", 379 | "gm": 308.0 380 | } 381 | ] 382 | }, 383 | { 384 | "ndbno": "14239", 385 | "name": "Alcoholic beverage, malt beer, hard lemonade", 386 | "weight": 335.0, 387 | "measure": "11.2 fl oz", 388 | "nutrients": [ 389 | { 390 | "nutrient_id": "208", 391 | "nutrient": "Energy", 392 | "unit": "kcal", 393 | "value": "228", 394 | "gm": 68.0 395 | } 396 | ] 397 | }, 398 | { 399 | "ndbno": "14015", 400 | "name": "Alcoholic beverage, pina colada, canned", 401 | "weight": 32.6, 402 | "measure": "1.0 fl oz", 403 | "nutrients": [ 404 | { 405 | "nutrient_id": "208", 406 | "nutrient": "Energy", 407 | "unit": "kcal", 408 | "value": "77", 409 | "gm": 237.0 410 | } 411 | ] 412 | }, 413 | { 414 | "ndbno": "14017", 415 | "name": "Alcoholic beverage, pina colada, prepared-from-recipe", 416 | "weight": 31.4, 417 | "measure": "1.0 fl oz", 418 | "nutrients": [ 419 | { 420 | "nutrient_id": "208", 421 | "nutrient": "Energy", 422 | "unit": "kcal", 423 | "value": "55", 424 | "gm": 174.0 425 | } 426 | ] 427 | }, 428 | { 429 | "ndbno": "43479", 430 | "name": "Alcoholic beverage, rice (sake)", 431 | "weight": 29.1, 432 | "measure": "1.0 fl oz", 433 | "nutrients": [ 434 | { 435 | "nutrient_id": "208", 436 | "nutrient": "Energy", 437 | "unit": "kcal", 438 | "value": "39", 439 | "gm": 134.0 440 | } 441 | ] 442 | }, 443 | { 444 | "ndbno": "14019", 445 | "name": "Alcoholic beverage, tequila sunrise, canned", 446 | "weight": 31.1, 447 | "measure": "1.0 fl oz", 448 | "nutrients": [ 449 | { 450 | "nutrient_id": "208", 451 | "nutrient": "Energy", 452 | "unit": "kcal", 453 | "value": "34", 454 | "gm": 110.0 455 | } 456 | ] 457 | }, 458 | { 459 | "ndbno": "14531", 460 | "name": "Alcoholic beverage, whiskey sour", 461 | "weight": 30.4, 462 | "measure": "1.0 fl oz", 463 | "nutrients": [ 464 | { 465 | "nutrient_id": "208", 466 | "nutrient": "Energy", 467 | "unit": "kcal", 468 | "value": "45", 469 | "gm": 149.0 470 | } 471 | ] 472 | }, 473 | { 474 | "ndbno": "14027", 475 | "name": "Alcoholic beverage, whiskey sour, canned", 476 | "weight": 30.8, 477 | "measure": "1.0 fl oz", 478 | "nutrients": [ 479 | { 480 | "nutrient_id": "208", 481 | "nutrient": "Energy", 482 | "unit": "kcal", 483 | "value": "37", 484 | "gm": 119.0 485 | } 486 | ] 487 | }, 488 | { 489 | "ndbno": "14029", 490 | "name": "Alcoholic beverage, whiskey sour, prepared from item 14028", 491 | "weight": 30.4, 492 | "measure": "1.0 fl oz", 493 | "nutrients": [ 494 | { 495 | "nutrient_id": "208", 496 | "nutrient": "Energy", 497 | "unit": "kcal", 498 | "value": "47", 499 | "gm": 153.0 500 | } 501 | ] 502 | }, 503 | { 504 | "ndbno": "14025", 505 | "name": "Alcoholic beverage, whiskey sour, prepared with water, whiskey and powder mix", 506 | "weight": 29.4, 507 | "measure": "1.0 fl oz", 508 | "nutrients": [ 509 | { 510 | "nutrient_id": "208", 511 | "nutrient": "Energy", 512 | "unit": "kcal", 513 | "value": "48", 514 | "gm": 164.0 515 | } 516 | ] 517 | }, 518 | { 519 | "ndbno": "43154", 520 | "name": "Alcoholic beverage, wine, cooking", 521 | "weight": 4.9, 522 | "measure": "1.0 tsp", 523 | "nutrients": [ 524 | { 525 | "nutrient_id": "208", 526 | "nutrient": "Energy", 527 | "unit": "kcal", 528 | "value": "2", 529 | "gm": 50.0 530 | } 531 | ] 532 | }, 533 | { 534 | "ndbno": "14536", 535 | "name": "Alcoholic beverage, wine, dessert, dry", 536 | "weight": 29.5, 537 | "measure": "1.0 fl oz", 538 | "nutrients": [ 539 | { 540 | "nutrient_id": "208", 541 | "nutrient": "Energy", 542 | "unit": "kcal", 543 | "value": "45", 544 | "gm": 152.0 545 | } 546 | ] 547 | }, 548 | { 549 | "ndbno": "14057", 550 | "name": "Alcoholic beverage, wine, dessert, sweet", 551 | "weight": 29.5, 552 | "measure": "1.0 fl oz", 553 | "nutrients": [ 554 | { 555 | "nutrient_id": "208", 556 | "nutrient": "Energy", 557 | "unit": "kcal", 558 | "value": "47", 559 | "gm": 160.0 560 | } 561 | ] 562 | } 563 | ] 564 | } 565 | } 566 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | // If file is less than 10KB, turn it into dataURI 4 | // else, use the raw asset and save it to a separate folder. 5 | const embedFileSize = 10000 6 | 7 | const assetsLoaders = [{ 8 | test: /\.css$/, 9 | use: ['style-loader', 'css-loader'] 10 | }, { 11 | test: /\.styl$/, 12 | use: [ 13 | { loader: 'style-loader', options: { sourceMap: false } }, 14 | { loader: 'css-loader', options: { sourceMap: false } }, 15 | { 16 | loader: 'postcss-loader', 17 | options: { 18 | plugins: function () { 19 | return [require('autoprefixer')] 20 | }, 21 | sourceMap: false 22 | } 23 | }, 24 | { loader: 'stylus-loader', options: { sourceMap: false } } 25 | ] 26 | }, { 27 | test: /\.(ttf|woff|woff2)$/, 28 | use: 'url-loader' 29 | }, { 30 | test: /\.json$/, 31 | use: 'json-loader' 32 | }, { 33 | test: /\.(jpe?g|png|gif|svg)$/, 34 | use: [{ 35 | loader: 'url-loader', 36 | options: { 37 | limit: embedFileSize, 38 | name: 'img/[name].[sha1:hash:base64:7].[ext]' 39 | } 40 | }] 41 | }] 42 | 43 | const babelLoader = { 44 | test: /\.jsx?$/, 45 | exclude: /node_modules/, 46 | use: { 47 | loader: 'babel-loader', 48 | options: { 49 | cacheDirectory: true 50 | } 51 | } 52 | } 53 | 54 | const lintLoader = { 55 | test: /\.jsx?$/, 56 | exclude: /node_modules/, 57 | enforce: 'pre', 58 | loader: 'eslint-loader' 59 | } 60 | 61 | module.exports = { 62 | devtool: 'cheap-hidden-source-map', 63 | entry: [ 64 | './src/app' 65 | ], 66 | output: { 67 | path: path.join(__dirname, 'build'), 68 | filename: 'app.js', 69 | publicPath: '/' 70 | }, 71 | resolve: { 72 | extensions: ['*', '.js', '.styl'] 73 | }, 74 | plugins: [ 75 | new webpack.optimize.UglifyJsPlugin({ 76 | compressor: { warnings: false } 77 | }), 78 | new webpack.DefinePlugin({ 79 | 'process.env': {NODE_ENV: JSON.stringify('production')} 80 | }), 81 | new webpack.optimize.AggressiveMergingPlugin(), 82 | new webpack.NoEmitOnErrorsPlugin() 83 | ], 84 | module: { 85 | rules: [ 86 | ...assetsLoaders, 87 | babelLoader, 88 | lintLoader 89 | ] 90 | }, 91 | stats: { 92 | chunkModules: false, 93 | errors: true, 94 | colors: true 95 | }, 96 | // Hide log for assets exceeding the recommended limit of 250 kB 97 | performance: { 98 | hints: false 99 | } 100 | } 101 | --------------------------------------------------------------------------------