├── .babelrc ├── .eslintrc.js ├── .flowconfig ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── css └── table-twbs.css ├── example ├── data.json ├── redux │ ├── index.html │ └── index.js ├── table │ ├── index.html │ └── main.js └── webpack.config.js ├── package.json ├── src ├── DataTable.js ├── Pagination.js ├── PartialTable.js ├── ReduxTable.js ├── Table.js ├── __tests__ │ ├── Pagination-test.js │ ├── Table-test.js │ └── dataReducer-test.js ├── actions.js ├── dataReducer.js ├── enhanceDataTable.js ├── index.js ├── selectors.js ├── types.js └── utils.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-1" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const OFF = 0; 2 | const WARNING = 1; 3 | const ERROR = 2; 4 | 5 | module.exports = { 6 | parser: 'babel-eslint', 7 | 8 | plugins: [ 9 | 'react', 10 | ], 11 | 12 | parserOptions: { 13 | ecmaVersion: 6, 14 | sourceType: 'module', 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | }, 19 | 20 | env: { 21 | browser: true, 22 | node: true, 23 | jasmine: true, 24 | es6: true, 25 | }, 26 | 27 | globals: { 28 | jest: false, 29 | }, 30 | 31 | rules: { 32 | 'strict': OFF, 33 | 'quotes': [ERROR, 'single'], 34 | 'max-len': [ERROR, 80], 35 | 'curly': [ERROR, 'multi-line'], 36 | 'eqeqeq': [ERROR, 'smart'], 37 | 'block-scoped-var': ERROR, 38 | 'semi': [WARNING, 'always'], 39 | 'space-before-blocks': [WARNING, 'always'], 40 | 'space-in-parens': [OFF, 'never'], 41 | 'comma-dangle': [ERROR, 'only-multiline'], 42 | 'no-underscore-dangle': 0, 43 | 'no-delete-var': ERROR, 44 | 'react/jsx-uses-react': 1, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [options] 8 | esproposal.class_instance_fields=enable 9 | esproposal.class_static_fields=enable 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | node_modules 4 | lib 5 | coverage 6 | dist 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "6.2.2" 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0 2 | Overall just internal changes. 3 | * Remove DataMixin in favor of a component. 4 | * Use a reducer for changes of data. 5 | * Use React 15. 6 | 7 | ## 0.6.0 8 | * Support for React 0.14. 9 | * Use lodash for sorting. 10 | 11 | ## 0.5.0 12 | * Pass down `buildRowOptions` to Table. 13 | 14 | ## 0.4.0 15 | 16 | * Reinitialize state when props change. (Fixes #5). 17 | 18 | ## 0.3.0 19 | 20 | * Support for React 0.13.0. 21 | 22 | ## 0.2.0 23 | 24 | * Add aria- attributes for accessibility. 25 | 26 | ### Breaking changes 27 | 28 | * The values for order now are 'ascending' and 'descending' instead of 'asc' and 'desc'. 29 | 30 | ## 0.1.1 31 | 32 | * Use css instead of less. 33 | * Use MIT license. 34 | 35 | ## 0.1.0 36 | 37 | * Initial release. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Carlos Rocha 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 | # react-data-components 2 | 3 | [![Build Status](https://travis-ci.org/carlosrocha/react-data-components.svg?branch=master)](https://travis-ci.org/carlosrocha/react-data-components) 4 | 5 | DataTable: [Live demo and source](https://jsfiddle.net/carlosrocha/xgde4uh0/) 6 | 7 | SelectableTable: [Live demo and source](https://jsfiddle.net/carlosrocha/p9pouh1v/) 8 | 9 | ## Getting started 10 | 11 | ```sh 12 | npm install react-data-components --save 13 | ``` 14 | 15 | This component requires Bootstrap stylesheet and Font Awesome fonts, in addition 16 | to the [stylesheet for headers](css/table-twbs.css). If you are using Webpack 17 | and the `css-loader` you can also require the css 18 | with `require('react-data-components/css/table-twbs.css')`. 19 | 20 | ### Using the default implementation 21 | 22 | The default implementation includes a filter for case insensitive global search, 23 | pagination and page size. 24 | 25 | ```javascript 26 | var React = require('react'); 27 | var ReactDOM = require('react-dom'); 28 | var DataTable = require('react-data-components').DataTable; 29 | 30 | var columns = [ 31 | { title: 'Name', prop: 'name' }, 32 | { title: 'City', prop: 'city' }, 33 | { title: 'Address', prop: 'address' }, 34 | { title: 'Phone', prop: 'phone' } 35 | ]; 36 | 37 | var data = [ 38 | { name: 'name value', city: 'city value', address: 'address value', phone: 'phone value' } 39 | // It also supports arrays 40 | // [ 'name value', 'city value', 'address value', 'phone value' ] 41 | ]; 42 | 43 | ReactDOM.render(( 44 | 51 | ), document.getElementById('root')); 52 | ``` 53 | 54 | See [complete example](example/table/main.js). 55 | 56 | -------------------------------------------------------------------------------- /css/table-twbs.css: -------------------------------------------------------------------------------- 1 | .table th { 2 | cursor: pointer; 3 | text-align: center; 4 | -webkit-user-select: none; 5 | -moz-user-select: none; 6 | -ms-user-select: none; 7 | user-select: none; 8 | } 9 | .table .empty-cell { 10 | font-style: italic; 11 | color: darkgray; 12 | cursor: default; 13 | -webkit-user-select: none; 14 | -moz-user-select: none; 15 | -ms-user-select: none; 16 | user-select: none; 17 | } 18 | 19 | .sort-icon:after { 20 | float: right; 21 | color: hsl(20, 0%, 70%); 22 | display: inline-block; 23 | font: normal normal normal 14px/1 FontAwesome; 24 | font-size: inherit; 25 | text-rendering: auto; 26 | -webkit-font-smoothing: antialiased; 27 | -moz-osx-font-smoothing: grayscale; 28 | } 29 | .sort-icon.sort-ascending:after { content: '\f0de'; } 30 | .sort-icon.sort-descending:after { content: '\f0dd'; } 31 | .sort-icon.sort-none:after { content: '\f0dc'; } 32 | -------------------------------------------------------------------------------- /example/redux/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /example/redux/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { createStore, combineReducers } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import { 6 | dataReducer, 7 | actions, 8 | ReduxTable as DataTable, 9 | } from 'react-data-components'; 10 | 11 | const TABLE = 'react-data-components-example'; 12 | const store = createStore(combineReducers({ datatable: dataReducer })); 13 | 14 | const renderMapUrl = (val, row) => 15 | 16 | Google Maps 17 | ; 18 | 19 | render( 20 | 21 | ' }, 30 | { title: 'Map', render: renderMapUrl, className: 'text-center' }, 31 | ]} 32 | /> 33 | , 34 | document.getElementById('root'), 35 | ); 36 | 37 | fetch('/data.json').then(res => res.json()).then(data => { 38 | store.dispatch(actions.dataLoaded(data, TABLE)); 39 | }); 40 | -------------------------------------------------------------------------------- /example/table/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /example/table/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { DataTable } from 'react-data-components'; 4 | 5 | function buildTable(data) { 6 | const renderMapUrl = 7 | (val, row) => 8 | 9 | Google Maps 10 | ; 11 | 12 | const tableColumns = [ 13 | { title: 'Name', prop: 'name' }, 14 | { title: 'City', prop: 'city' }, 15 | { title: 'Street address', prop: 'street' }, 16 | { title: 'Phone', prop: 'phone', defaultContent: '' }, 17 | { title: 'Map', render: renderMapUrl, className: 'text-center' }, 18 | ]; 19 | 20 | return ( 21 | 30 | ); 31 | } 32 | 33 | fetch('/data.json') 34 | .then(res => res.json()) 35 | .then((rows) => { 36 | ReactDOM.render(buildTable(rows), document.getElementById('root')); 37 | }); 38 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | context: __dirname, 5 | devServer: { 6 | contentBase: __dirname, 7 | }, 8 | entry: { 9 | table: './table/main', 10 | redux: './redux/index', 11 | }, 12 | output: { 13 | filename: '[name].entry.js', 14 | }, 15 | resolve: { 16 | alias: { 17 | // Use uncompiled version 18 | 'react-data-components': path.join(__dirname, '../src'), 19 | }, 20 | }, 21 | module: { 22 | loaders: [ 23 | { 24 | test: /\.js$/, 25 | exclude: /node_modules/, 26 | loader: 'babel-loader', 27 | }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-data-components", 3 | "version": "1.2.0", 4 | "description": "React data components", 5 | "keywords": [ 6 | "pagination", 7 | "react", 8 | "react-component", 9 | "table" 10 | ], 11 | "author": "Carlos Rocha", 12 | "license": "MIT", 13 | "main": "./lib/index", 14 | "files": [ 15 | "css", 16 | "lib", 17 | "dist", 18 | "src" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/carlosrocha/react-data-components" 23 | }, 24 | "dependencies": { 25 | "lodash": "^4.13.1", 26 | "prop-types": "^15.5.10" 27 | }, 28 | "peerDependencies": { 29 | "react": "^0.14.0 || ^15.0.0-0" 30 | }, 31 | "devDependencies": { 32 | "babel-cli": "^6.10.1", 33 | "babel-core": "^6.10.4", 34 | "babel-jest": "^20.0.3", 35 | "babel-loader": "^7.1.1", 36 | "babel-plugin-lodash": "^3.2.4", 37 | "babel-preset-es2015": "^6.9.0", 38 | "babel-preset-react": "^6.5.0", 39 | "babel-preset-stage-1": "^6.5.0", 40 | "flow-bin": "^0.49.1", 41 | "husky": "^0.14.1", 42 | "jest": "^20.0.4", 43 | "lint-staged": "^4.0.0", 44 | "lodash-webpack-plugin": "^0.11.4", 45 | "prettier": "^1.5.2", 46 | "react": "^15.1.0", 47 | "react-addons-test-utils": "^15.1.0", 48 | "react-dom": "^15.1.0", 49 | "react-redux": "^4.4.5", 50 | "react-test-renderer": "^15.6.1", 51 | "redux": "^3.5.2", 52 | "rimraf": "^2.4.3", 53 | "webpack": "^3.1.0", 54 | "webpack-dev-server": "^3.1.11" 55 | }, 56 | "scripts": { 57 | "build": "webpack && babel src -d lib", 58 | "check": "flow check", 59 | "clean": "rimraf lib dist coverage", 60 | "precommit": "lint-staged", 61 | "prepublish": "npm run clean && npm run build", 62 | "start": "webpack-dev-server -d --config example/webpack.config.js", 63 | "test": "npm run check && jest" 64 | }, 65 | "lint-staged": { 66 | "*.js": [ 67 | "prettier --write --single-quote --trailing-comma all", 68 | "git add" 69 | ] 70 | }, 71 | "jest": { 72 | "roots": [ 73 | "src" 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/DataTable.js: -------------------------------------------------------------------------------- 1 | import enhanceDataTable from './enhanceDataTable'; 2 | import PartialTable from './PartialTable'; 3 | 4 | export default enhanceDataTable(PartialTable); 5 | -------------------------------------------------------------------------------- /src/Pagination.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | // Used to cancel events. 5 | var preventDefault = e => e.preventDefault(); 6 | 7 | export default class Pagination extends Component { 8 | static defaultProps = { 9 | showPages: 5, 10 | }; 11 | 12 | static propTypes = { 13 | onChangePage: PropTypes.func.isRequired, 14 | totalPages: PropTypes.number.isRequired, 15 | currentPage: PropTypes.number.isRequired, 16 | showPages: PropTypes.number, 17 | }; 18 | 19 | shouldComponentUpdate(nextProps) { 20 | var props = this.props; 21 | 22 | return ( 23 | props.totalPages !== nextProps.totalPages || 24 | props.currentPage !== nextProps.currentPage || 25 | props.showPages !== nextProps.showPages 26 | ); 27 | } 28 | 29 | onChangePage(pageNumber, event) { 30 | event.preventDefault(); 31 | this.props.onChangePage(pageNumber); 32 | } 33 | 34 | render() { 35 | var { totalPages, showPages, currentPage } = this.props; 36 | 37 | if (totalPages === 0) { 38 | return null; 39 | } 40 | 41 | var diff = Math.floor(showPages / 2), 42 | start = Math.max(currentPage - diff, 0), 43 | end = Math.min(start + showPages, totalPages); 44 | 45 | if (totalPages >= showPages && end >= totalPages) { 46 | start = totalPages - showPages; 47 | } 48 | 49 | var buttons = [], 50 | btnEvent, 51 | isCurrent; 52 | for (var i = start; i < end; i++) { 53 | isCurrent = currentPage === i; 54 | // If the button is for the current page then disable the event. 55 | if (isCurrent) { 56 | btnEvent = preventDefault; 57 | } else { 58 | btnEvent = this.onChangePage.bind(this, i); 59 | } 60 | buttons.push( 61 |
  • 62 | 63 | 64 | {i + 1} 65 | 66 | {isCurrent ? (current) : null} 67 | 68 |
  • , 69 | ); 70 | } 71 | 72 | // First and Prev button handlers and class. 73 | var firstHandler = preventDefault; 74 | var prevHandler = preventDefault; 75 | var isNotFirst = currentPage > 0; 76 | if (isNotFirst) { 77 | firstHandler = this.onChangePage.bind(this, 0); 78 | prevHandler = this.onChangePage.bind(this, currentPage - 1); 79 | } 80 | 81 | // Next and Last button handlers and class. 82 | var nextHandler = preventDefault; 83 | var lastHandler = preventDefault; 84 | var isNotLast = currentPage < totalPages - 1; 85 | if (isNotLast) { 86 | nextHandler = this.onChangePage.bind(this, currentPage + 1); 87 | lastHandler = this.onChangePage.bind(this, totalPages - 1); 88 | } 89 | 90 | buttons = [ 91 |
  • 92 | 100 | 102 |
  • , 103 |
  • 104 | 112 | 114 |
  • , 115 | ].concat(buttons); 116 | 117 | buttons = buttons.concat([ 118 |
  • 119 | 127 | 129 |
  • , 130 |
  • 131 | 139 | 141 |
  • , 142 | ]); 143 | 144 | return ( 145 |
      146 | {buttons} 147 |
    148 | ); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/PartialTable.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Table from './Table'; 3 | import Pagination from './Pagination'; 4 | 5 | export default class PartialTable extends Component { 6 | render() { 7 | const { 8 | onFilter, 9 | onPageSizeChange, 10 | onPageNumberChange, 11 | onSort, 12 | pageLengthOptions, 13 | columns, 14 | keys, 15 | buildRowOptions, 16 | } = this.props; 17 | 18 | // Protect against unloaded data. 19 | if (!this.props.data) { 20 | return null; 21 | } 22 | 23 | const { 24 | page, 25 | pageSize, 26 | pageNumber, 27 | totalPages, 28 | sortBy, 29 | filterValues, 30 | } = this.props.data; 31 | 32 | return ( 33 |
    34 |
    35 |
    36 |
    37 | 38 | 49 |
    50 |
    51 | 52 | 58 |
    59 |
    60 |
    61 | 67 |
    68 |
    69 | 78 | 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/ReduxTable.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import PartialTable from './PartialTable'; 5 | import { 6 | initialize, 7 | pageNumberChange, 8 | pageSizeChange, 9 | dataFilter, 10 | } from './actions'; 11 | import { selectDataTable } from './selectors'; 12 | import { containsIgnoreCase } from './utils'; 13 | 14 | const defaultFilters = { 15 | globalSearch: { filter: containsIgnoreCase }, 16 | }; 17 | 18 | class DataTable extends Component { 19 | componentWillMount() { 20 | this.init(); 21 | } 22 | 23 | componentWillReceiveProps(nextProps) { 24 | this.init(nextProps); 25 | } 26 | 27 | init = nextProps => { 28 | if (!nextProps || !nextProps.initialized) { 29 | this.props.initialize(this.props.initialData); 30 | } 31 | }; 32 | 33 | onFilter = (key, { target: { value } }) => this.props.filter(key, value); 34 | 35 | onSort = value => this.props.sort(value); 36 | 37 | onPageSizeChange = ({ target: { value } }) => 38 | this.props.changePageSize(value); 39 | 40 | onPageNumberChange = value => this.props.changePageNumber(value); 41 | 42 | render() { 43 | return ( 44 | 51 | ); 52 | } 53 | } 54 | 55 | DataTable.propTypes = { 56 | table: PropTypes.string.isRequired, 57 | }; 58 | 59 | const mapStateToProps = (state, props) => { 60 | const { 61 | buildRowOptions, 62 | columns, 63 | initialData, 64 | keys, 65 | pageLengthOptions, 66 | table, 67 | } = props; 68 | 69 | const data = selectDataTable(table)(state); 70 | const initialized = data && data.initialized; 71 | 72 | return { 73 | buildRowOptions, 74 | columns, 75 | data, 76 | initialData, 77 | initialized, 78 | keys, 79 | pageLengthOptions, 80 | }; 81 | }; 82 | 83 | const mapDispatchToProps = (dispatch, ownProps) => { 84 | const filters = ownProps.filter || defaultFilters; 85 | const { table } = ownProps; 86 | 87 | return { 88 | changePageNumber: pageNumber => 89 | dispatch(pageNumberChange(pageNumber, table)), 90 | changePageSize: pageSize => dispatch(pageSizeChange(pageSize, table)), 91 | initialize: initialData => dispatch(initialize(initialData, table)), 92 | filter: (key, value) => dispatch(dataFilter(key, value, filters, table)), 93 | }; 94 | }; 95 | 96 | export default connect(mapStateToProps, mapDispatchToProps)(DataTable); 97 | -------------------------------------------------------------------------------- /src/Table.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const simpleGet = key => data => data[key]; 5 | const keyGetter = keys => data => keys.map(key => data[key]); 6 | 7 | const isEmpty = value => value == null || value === ''; 8 | 9 | const getCellValue = ({ prop, defaultContent, render }, row) => 10 | // Return `defaultContent` if the value is empty. 11 | !isEmpty(prop) && isEmpty(row[prop]) 12 | ? defaultContent 13 | : // Use the render function for the value. 14 | render 15 | ? render(row[prop], row) 16 | : // Otherwise just return the value. 17 | row[prop]; 18 | 19 | const getCellClass = ({ prop, className }, row) => 20 | !isEmpty(prop) && isEmpty(row[prop]) 21 | ? 'empty-cell' 22 | : typeof className == 'function' ? className(row[prop], row) : className; 23 | 24 | function buildSortProps(col, sortBy, onSort) { 25 | const order = sortBy && sortBy.prop === col.prop ? sortBy.order : 'none'; 26 | const nextOrder = order === 'ascending' ? 'descending' : 'ascending'; 27 | const sortEvent = onSort.bind(null, { prop: col.prop, order: nextOrder }); 28 | 29 | return { 30 | onClick: sortEvent, 31 | // Fire the sort event on enter. 32 | onKeyDown: e => { 33 | if (e.keyCode === 13) sortEvent(); 34 | }, 35 | // Prevents selection with mouse. 36 | onMouseDown: e => e.preventDefault(), 37 | tabIndex: 0, 38 | 'aria-sort': order, 39 | 'aria-label': `${col.title}: activate to sort column ${nextOrder}`, 40 | }; 41 | } 42 | 43 | export default class Table extends Component { 44 | _headers = []; 45 | 46 | static propTypes = { 47 | keys: PropTypes.oneOfType([ 48 | PropTypes.arrayOf(PropTypes.string), 49 | PropTypes.string, 50 | ]).isRequired, 51 | 52 | columns: PropTypes.arrayOf( 53 | PropTypes.shape({ 54 | title: PropTypes.string.isRequired, 55 | prop: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 56 | render: PropTypes.func, 57 | sortable: PropTypes.bool, 58 | defaultContent: PropTypes.string, 59 | width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 60 | className: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), 61 | }), 62 | ).isRequired, 63 | 64 | dataArray: PropTypes.arrayOf( 65 | PropTypes.oneOfType([PropTypes.array, PropTypes.object]), 66 | ).isRequired, 67 | 68 | buildRowOptions: PropTypes.func, 69 | 70 | sortBy: PropTypes.shape({ 71 | prop: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 72 | order: PropTypes.oneOf(['ascending', 'descending']), 73 | }), 74 | 75 | onSort: PropTypes.func, 76 | }; 77 | 78 | componentDidMount() { 79 | // If no width was specified, then set the width that the browser applied 80 | // initially to avoid recalculating width between pages. 81 | this._headers.forEach(header => { 82 | if (!header.style.width) { 83 | header.style.width = `${header.offsetWidth}px`; 84 | } 85 | }); 86 | } 87 | 88 | render() { 89 | const { 90 | columns, 91 | keys, 92 | buildRowOptions, 93 | sortBy, 94 | onSort, 95 | dataArray, 96 | ...otherProps 97 | } = this.props; 98 | 99 | const headers = columns.map((col, idx) => { 100 | let sortProps, order; 101 | // Only add sorting events if the column has a property and is sortable. 102 | if (onSort && col.sortable !== false && 'prop' in col) { 103 | sortProps = buildSortProps(col, sortBy, onSort); 104 | order = sortProps['aria-sort']; 105 | } 106 | 107 | return ( 108 | 123 | ); 124 | }); 125 | 126 | const getKeys = Array.isArray(keys) ? keyGetter(keys) : simpleGet(keys); 127 | const rows = dataArray.map(row => { 128 | const trProps = buildRowOptions ? buildRowOptions(row) : {}; 129 | 130 | return ( 131 | 132 | {columns.map((col, i) => 133 | , 136 | )} 137 | 138 | ); 139 | }); 140 | 141 | return ( 142 |
    (this._headers[idx] = c)} 110 | key={idx} 111 | style={{ width: col.width }} 112 | role="columnheader" 113 | scope="col" 114 | {...sortProps} 115 | > 116 | 117 | {col.title} 118 | 119 | {!order 120 | ? null 121 | :
    134 | {getCellValue(col, row)} 135 |
    143 | {!sortBy 144 | ? null 145 | : } 148 | 149 | 150 | {headers} 151 | 152 | 153 | 154 | {rows.length 155 | ? rows 156 | : 157 | 160 | } 161 | 162 |
    146 | {`Sorted by ${sortBy.prop}: ${sortBy.order} order`} 147 |
    158 | No data 159 |
    163 | ); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/__tests__/Pagination-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import shallow from 'react-test-renderer/shallow'; 3 | import Pagination from '../Pagination'; 4 | 5 | describe('Pagination', () => { 6 | let onChangePage; 7 | 8 | beforeEach(() => { 9 | onChangePage = jest.genMockFunction(); 10 | }); 11 | 12 | it('renders the correct buttons', () => { 13 | const showPages = 10; 14 | const currentPage = 5; 15 | const totalPages = 10; 16 | 17 | const shallowRenderer = shallow.createRenderer(); 18 | shallowRenderer.render( 19 | , 25 | ); 26 | 27 | const result = shallowRenderer.getRenderOutput(); 28 | 29 | // 4 buttons for first, prev, next and last 30 | expect(result.props.children.length).toBe(showPages + 4); 31 | }); 32 | 33 | it('disables prev and first button when on first page', () => { 34 | const currentPage = 0; 35 | const totalPages = 10; 36 | 37 | const shallowRenderer = shallow.createRenderer(); 38 | shallowRenderer.render( 39 | , 44 | ); 45 | 46 | const result = shallowRenderer.getRenderOutput(); 47 | expect(result.props.children[0].props.className).toEqual('disabled'); 48 | expect(result.props.children[1].props.className).toEqual('disabled'); 49 | expect(onChangePage).not.toBeCalled(); 50 | }); 51 | 52 | it('disables next and last button when on last page', () => { 53 | const currentPage = 9; 54 | const totalPages = 10; 55 | 56 | const shallowRenderer = shallow.createRenderer(); 57 | shallowRenderer.render( 58 | , 63 | ); 64 | 65 | const { children } = shallowRenderer.getRenderOutput().props; 66 | const totalChildren = children.length; 67 | 68 | expect(children[totalChildren - 2].props.className).toEqual('disabled'); 69 | expect(children[totalChildren - 1].props.className).toEqual('disabled'); 70 | expect(onChangePage).not.toBeCalled(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/__tests__/Table-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import shallow from 'react-test-renderer/shallow'; 3 | import Table from '../Table'; 4 | 5 | describe('Table', () => { 6 | it('shows message when no data', () => { 7 | const columns = [{ title: 'Test', prop: 'test' }]; 8 | const shallowRenderer = shallow.createRenderer(); 9 | shallowRenderer.render( 10 | , 11 | ); 12 | 13 | const result = shallowRenderer.getRenderOutput(); 14 | 15 | expect(result.props.children[2]).toEqual( 16 | 17 | 18 | 21 | 22 | , 23 | ); 24 | }); 25 | 26 | it('render simple', () => { 27 | const columns = [{ title: 'Test', prop: 'test' }]; 28 | const shallowRenderer = shallow.createRenderer(); 29 | shallowRenderer.render( 30 |
    19 | No data 20 |
    , 31 | ); 32 | 33 | const result = shallowRenderer.getRenderOutput(); 34 | 35 | expect(result.props.children[2]).toEqual( 36 | 37 | {[ 38 | 39 | {[ 40 | , 43 | ]} 44 | , 45 | ]} 46 | , 47 | ); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/__tests__/dataReducer-test.js: -------------------------------------------------------------------------------- 1 | import { dataReducer } from '../dataReducer'; 2 | import { 3 | pageNumberChange, 4 | pageSizeChange, 5 | dataSort, 6 | dataFilter, 7 | dataLoaded, 8 | } from '../actions'; 9 | import { containsIgnoreCase } from '../utils'; 10 | 11 | const data = [[1, 2], [3, 4]]; 12 | 13 | const filters = { 14 | globalSearch: { filter: containsIgnoreCase }, 15 | }; 16 | 17 | describe('dataReducer', () => { 18 | it('loads data', () => { 19 | const action = dataLoaded(data); 20 | const expected = { 21 | data, 22 | initialized: false, 23 | initialData: data, 24 | page: data, 25 | filterValues: { globalSearch: '' }, 26 | sortBy: null, 27 | pageNumber: 0, 28 | pageSize: 5, 29 | totalPages: 1, 30 | }; 31 | 32 | expect(dataReducer(undefined, action)).toEqual(expected); 33 | }); 34 | 35 | it('changes page number', () => { 36 | const state = { 37 | data, 38 | page: data.slice(0, 1), 39 | pageNumber: 0, 40 | pageSize: 1, 41 | }; 42 | const action = pageNumberChange(1); 43 | const expected = { 44 | ...state, 45 | page: data.slice(1, 2), 46 | pageNumber: 1, 47 | totalPages: 2, 48 | }; 49 | 50 | expect(dataReducer(state, action)).toEqual(expected); 51 | }); 52 | 53 | it('changes page size', () => { 54 | const state = { 55 | data, 56 | page: data.slice(0, 1), 57 | pageNumber: 0, 58 | pageSize: 1, 59 | }; 60 | const action = pageSizeChange(2); 61 | const expected = { 62 | ...state, 63 | page: data, 64 | pageSize: 2, 65 | totalPages: 1, 66 | }; 67 | 68 | expect(dataReducer(state, action)).toEqual(expected); 69 | }); 70 | 71 | it('sorts descending', () => { 72 | const state = { 73 | data: [[3, 4], [1, 2]], 74 | page: [[3, 4]], 75 | pageNumber: 0, 76 | pageSize: 1, 77 | totalPages: 2, 78 | }; 79 | const sortBy = { prop: 0, order: 'ascending' }; 80 | const action = dataSort(sortBy); 81 | const expected = { 82 | ...state, 83 | sortBy, 84 | data: [[1, 2], [3, 4]], 85 | page: [[1, 2]], 86 | }; 87 | 88 | expect(dataReducer(state, action)).toEqual(expected); 89 | }); 90 | 91 | it('sorts descending', () => { 92 | const state = { 93 | data: [[1, 2], [3, 4]], 94 | page: [[1, 2]], 95 | pageNumber: 0, 96 | pageSize: 1, 97 | totalPages: 2, 98 | }; 99 | const sortBy = { prop: 0, order: 'descending' }; 100 | const action = dataSort(sortBy); 101 | const expected = { 102 | ...state, 103 | sortBy, 104 | data: [[3, 4], [1, 2]], 105 | page: [[3, 4]], 106 | }; 107 | 108 | expect(dataReducer(state, action)).toEqual(expected); 109 | }); 110 | 111 | it('filters', () => { 112 | const data = [['carlos', 'r'], [3, 4]]; 113 | const state = { 114 | data, 115 | initialData: data, 116 | page: data.slice(0, 1), 117 | pageNumber: 0, 118 | pageSize: 1, 119 | totalPages: 2, 120 | }; 121 | const action = dataFilter('globalSearch', 'c', filters); 122 | const expected = { 123 | ...state, 124 | filterValues: { globalSearch: 'c' }, 125 | data: [['carlos', 'r']], 126 | page: [['carlos', 'r']], 127 | totalPages: 1, 128 | }; 129 | 130 | expect(dataReducer(state, action)).toEqual(expected); 131 | }); 132 | 133 | it('filters on different page', () => { 134 | const data = [['carlos', 'r'], [3, 4]]; 135 | const state = { 136 | data, 137 | initialData: data, 138 | page: data.slice(0, 1), 139 | pageSize: 1, 140 | totalPages: 2, 141 | }; 142 | const initState = { 143 | ...state, 144 | pageNumber: 1, 145 | }; 146 | const action = dataFilter('globalSearch', 'c', filters); 147 | const expected = { 148 | ...state, 149 | pageNumber: 0, 150 | filterValues: { globalSearch: 'c' }, 151 | data: [['carlos', 'r']], 152 | page: [['carlos', 'r']], 153 | totalPages: 1, 154 | }; 155 | 156 | expect(dataReducer(state, action)).toEqual(expected); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @flow 3 | */ 4 | 5 | import type { Action, SortBy, Value, Filters } from './types'; 6 | 7 | export const DOMAIN = 'react-data-components'; 8 | export const ActionTypes = { 9 | DATA_LOADED: `@@${DOMAIN}/DATA_LOADED`, 10 | INITIALIZE: `@@${DOMAIN}/INITIALIZE`, 11 | PAGE_NUMBER_CHANGE: `@@${DOMAIN}/PAGE_NUMBER_CHANGE`, 12 | PAGE_SIZE_CHANGE: `@@${DOMAIN}/PAGE_SIZE_CHANGE`, 13 | DATA_FILTER: `@@${DOMAIN}/DATA_FILTER`, 14 | DATA_SORT: `@@${DOMAIN}/DATA_SORT`, 15 | }; 16 | 17 | type DataFilterAction = { 18 | type: string, 19 | meta: { table: string }, 20 | payload: { key: string, value: Value, filters: Filters }, 21 | } & Action; 22 | 23 | type DataLoadedAction = { 24 | type: string, 25 | meta: { table: string }, 26 | payload: Array, 27 | } & Action; 28 | 29 | type DataSortAction = { 30 | type: string, 31 | meta: { table: string }, 32 | payload: SortBy, 33 | } & Action; 34 | 35 | type InitializeAction = { 36 | type: string, 37 | meta: { table: string }, 38 | payload: Array, 39 | } & Action; 40 | 41 | type PageNumberChangeAction = { 42 | type: string, 43 | meta: { table: string }, 44 | payload: number, 45 | } & Action; 46 | 47 | type PageSizeChangeAction = { 48 | type: string, 49 | meta: { table: string }, 50 | payload: number, 51 | } & Action; 52 | 53 | export const initialize = ( 54 | data: Array = [], 55 | table: string, 56 | ): InitializeAction => ({ 57 | type: ActionTypes.INITIALIZE, 58 | payload: data, 59 | meta: { table }, 60 | }); 61 | 62 | // Probably a bad idea to send down `filters` here. 63 | export const dataFilter = ( 64 | key: string, 65 | value: Value, 66 | filters: Filters, 67 | table: string, 68 | ): DataFilterAction => ({ 69 | type: ActionTypes.DATA_FILTER, 70 | payload: { key, value, filters }, 71 | meta: { table }, 72 | }); 73 | 74 | export const dataSort = (sortBy: SortBy, table: string): DataSortAction => ({ 75 | type: ActionTypes.DATA_SORT, 76 | payload: sortBy, 77 | meta: { table }, 78 | }); 79 | 80 | export const dataLoaded = ( 81 | data: Array, 82 | table: string, 83 | ): DataLoadedAction => ({ 84 | type: ActionTypes.DATA_LOADED, 85 | payload: data, 86 | meta: { table }, 87 | }); 88 | 89 | export const pageNumberChange = ( 90 | pageNumber: number, 91 | table: string, 92 | ): PageNumberChangeAction => ({ 93 | type: ActionTypes.PAGE_NUMBER_CHANGE, 94 | payload: pageNumber, 95 | meta: { table }, 96 | }); 97 | 98 | export const pageSizeChange = ( 99 | pageSize: number, 100 | table: string, 101 | ): PageSizeChangeAction => ({ 102 | type: ActionTypes.PAGE_SIZE_CHANGE, 103 | payload: pageSize, 104 | meta: { table }, 105 | }); 106 | -------------------------------------------------------------------------------- /src/dataReducer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @flow 3 | */ 4 | 5 | import { sort, filter } from './utils'; 6 | import { ActionTypes, DOMAIN } from './actions'; 7 | import type { State, Action, SortBy } from './types'; 8 | 9 | const initialState: State = { 10 | initialized: false, 11 | initialData: [], 12 | data: [], 13 | page: [], 14 | filterValues: { globalSearch: '' }, 15 | totalPages: 0, 16 | sortBy: null, 17 | pageNumber: 0, 18 | pageSize: 5, 19 | }; 20 | 21 | function calculatePage(data, pageSize, pageNumber) { 22 | if (pageSize === 0 || !data.length) { 23 | return { page: data, totalPages: 0 }; 24 | } 25 | 26 | const start = pageSize * pageNumber; 27 | 28 | return { 29 | page: data.slice(start, start + pageSize), 30 | totalPages: Math.ceil(data.length / pageSize), 31 | }; 32 | } 33 | 34 | function pageNumberChange(state, { payload: pageNumber }) { 35 | return { 36 | ...state, 37 | ...calculatePage(state.data, state.pageSize, pageNumber), 38 | pageNumber, 39 | }; 40 | } 41 | 42 | function pageSizeChange(state, action) { 43 | const newPageSize = Number(action.payload); 44 | const { pageNumber, pageSize } = state; 45 | const newPageNumber = newPageSize 46 | ? Math.floor(pageNumber * pageSize / newPageSize) 47 | : 0; 48 | 49 | return { 50 | ...state, 51 | ...calculatePage(state.data, newPageSize, newPageNumber), 52 | pageSize: newPageSize, 53 | pageNumber: newPageNumber, 54 | }; 55 | } 56 | 57 | function dataSort(state, { payload: sortBy }) { 58 | const data = sort(sortBy, state.data); 59 | 60 | return { 61 | ...state, 62 | ...calculatePage(data, state.pageSize, state.pageNumber), 63 | sortBy, 64 | data, 65 | }; 66 | } 67 | 68 | function dataFilter(state, { payload: { key, value, filters } }) { 69 | const newFilterValues = { ...state.filterValues, [key]: value }; 70 | let data = filter(filters, newFilterValues, state.initialData); 71 | 72 | if (state.sortBy) { 73 | data = sort(state.sortBy, data); 74 | } 75 | 76 | return { 77 | ...state, 78 | ...calculatePage(data, state.pageSize, 0), 79 | data, 80 | filterValues: newFilterValues, 81 | pageNumber: 0, 82 | }; 83 | } 84 | 85 | function dataInit(state, action) { 86 | const { payload } = action; 87 | 88 | return { 89 | ...state, 90 | initialized: true, 91 | initialData: payload, 92 | data: payload, 93 | }; 94 | } 95 | 96 | function dataLoaded(state, { payload: data }) { 97 | // Filled missing properties. 98 | const filledState = { ...initialState, ...state }; 99 | const { pageSize, pageNumber } = filledState; 100 | 101 | if (state.sortBy) { 102 | data = sort(state.sortBy, data); 103 | } 104 | 105 | return { 106 | ...filledState, 107 | ...calculatePage(data, pageSize, pageNumber), 108 | initialData: data, 109 | data, 110 | }; 111 | } 112 | 113 | export function dataReducer( 114 | state: State = initialState, 115 | action: Action, 116 | ): State { 117 | switch (action.type) { 118 | case ActionTypes.INITIALIZE: 119 | return dataInit(state, action); 120 | 121 | case ActionTypes.DATA_LOADED: 122 | return dataLoaded(state, action); 123 | 124 | case ActionTypes.PAGE_NUMBER_CHANGE: 125 | return pageNumberChange(state, action); 126 | 127 | case ActionTypes.PAGE_SIZE_CHANGE: 128 | return pageSizeChange(state, action); 129 | 130 | case ActionTypes.DATA_FILTER: 131 | return dataFilter(state, action); 132 | 133 | case ActionTypes.DATA_SORT: 134 | return dataSort(state, action); 135 | } 136 | 137 | return state; 138 | } 139 | 140 | export default function tableReducer(state: Object = {}, action: Action) { 141 | switch (action.type) { 142 | case ActionTypes.INITIALIZE: 143 | case ActionTypes.DATA_LOADED: 144 | case ActionTypes.PAGE_NUMBER_CHANGE: 145 | case ActionTypes.PAGE_SIZE_CHANGE: 146 | case ActionTypes.DATA_FILTER: 147 | case ActionTypes.DATA_SORT: { 148 | const { meta: { table } } = action; 149 | 150 | return { 151 | ...state, 152 | [table]: dataReducer(state[table], action), 153 | }; 154 | } 155 | } 156 | 157 | return state; 158 | } 159 | -------------------------------------------------------------------------------- /src/enhanceDataTable.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { dataReducer } from './dataReducer'; 3 | import { 4 | dataLoaded, 5 | dataSort, 6 | dataFilter, 7 | pageNumberChange, 8 | pageSizeChange, 9 | } from './actions'; 10 | import { containsIgnoreCase } from './utils'; 11 | import type { State } from './types'; 12 | 13 | type Props = { 14 | pageLengthOptions: Array, 15 | initialData: Array, 16 | initialPageLength: number, 17 | columns: Array, 18 | keys: Array, 19 | buildRowOptions: any, 20 | filters: any, 21 | }; 22 | 23 | const mapPropsToState = props => ({ 24 | pageSize: props.initialPageLength, 25 | sortBy: props.initialSortBy, 26 | }); 27 | 28 | export default function enhanceDataTable(ComposedComponent) { 29 | return class DataTableEnhancer extends Component { 30 | static defaultProps = { 31 | initialPageLength: 10, 32 | pageLengthOptions: [5, 10, 20], 33 | filters: { 34 | globalSearch: { filter: containsIgnoreCase }, 35 | }, 36 | }; 37 | 38 | constructor(props: Props) { 39 | super(props); 40 | this.state = dataReducer( 41 | mapPropsToState(props), 42 | dataLoaded(props.initialData), 43 | ); 44 | } 45 | 46 | componentWillReceiveProps(nextProps) { 47 | this.setState(state => 48 | dataReducer(state, dataLoaded(nextProps.initialData)), 49 | ); 50 | } 51 | 52 | onPageNumberChange = value => { 53 | this.setState(state => dataReducer(state, pageNumberChange(value))); 54 | }; 55 | 56 | onPageSizeChange = ({ target: { value } }) => { 57 | this.setState(state => dataReducer(state, pageSizeChange(value))); 58 | }; 59 | 60 | onSort = value => { 61 | this.setState(state => dataReducer(state, dataSort(value))); 62 | }; 63 | 64 | onFilter = (key, { target: { value } }) => { 65 | this.setState(state => 66 | dataReducer(state, dataFilter(key, value, this.props.filters)), 67 | ); 68 | }; 69 | 70 | render() { 71 | return ( 72 | 80 | ); 81 | } 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export DataTable from './DataTable'; 2 | export PartialTable from './PartialTable'; 3 | export ReduxTable from './ReduxTable'; 4 | export Table from './Table'; 5 | export Pagination from './Pagination'; 6 | export dataReducer from './dataReducer'; 7 | export * as utils from './utils'; 8 | export * as actions from './actions'; 9 | export * as selectors from './selectors'; 10 | -------------------------------------------------------------------------------- /src/selectors.js: -------------------------------------------------------------------------------- 1 | export const selectDataTable = (table: string) => state => 2 | state.datatable[table]; 3 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @flow 3 | */ 4 | 5 | import { ActionTypes } from './actions'; 6 | 7 | export type Value = string | number; 8 | 9 | export type Filters = { 10 | [name: string]: { 11 | filter: (a: Value, b: Value) => Boolean, 12 | }, 13 | }; 14 | 15 | export type Row = { [key: string]: string } | Array; 16 | 17 | export type AppData = Array; 18 | 19 | export type State = { 20 | initialized: boolean, 21 | initialData: AppData, 22 | data: AppData, 23 | page: AppData, 24 | sortBy: ?SortBy, 25 | pageSize: number, 26 | pageNumber: number, 27 | totalPages: number, 28 | filterValues: { 29 | [key: string]: string, 30 | }, 31 | }; 32 | 33 | export type Action = { 34 | type: string, 35 | payload: any, 36 | meta: { table: string }, 37 | error?: any, 38 | }; 39 | 40 | export type SortBy = { 41 | prop: Value, 42 | order: 'ascending' | 'descending', 43 | }; 44 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import orderBy from 'lodash/orderBy'; 2 | import some from 'lodash/some'; 3 | import type {SortBy, AppData, Value, Filters} from './types'; 4 | 5 | export function sort({prop, order}: SortBy, data: AppData) { 6 | return orderBy(data, prop, order === 'descending' ? 'desc' : 'asc'); 7 | } 8 | 9 | export function filter(filters: Filters, filterValues, data: AppData) { 10 | const filterAndVals = {}; 11 | for (let key in filterValues) { 12 | filterAndVals[key] = { 13 | value: filterValues[key], 14 | filter: filters[key].filter, 15 | prop: filters[key].prop, 16 | }; 17 | } 18 | 19 | return data.filter((row) => some( 20 | filterAndVals, 21 | ({filter, value, prop}) => 22 | !prop ? some(row, filter.bind(null, value)) : filter(value, row[key]) 23 | )); 24 | } 25 | 26 | export function containsIgnoreCase(a: Value, b: Value) { 27 | a = String(a).toLowerCase().trim(); 28 | b = String(b).toLowerCase().trim(); 29 | return b.indexOf(a) >= 0; 30 | } 31 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const webpack = require('webpack'); 5 | const LodashModuleReplacementPlugin = require('lodash-webpack-plugin'); 6 | 7 | module.exports = { 8 | entry: './src/index', 9 | output: { 10 | path: path.join(__dirname, 'dist'), 11 | filename: 'react-data-components.min.js', 12 | library: 'ReactDataComponents', 13 | libraryTarget: 'umd', 14 | }, 15 | externals: { 16 | react: { 17 | root: 'React', 18 | amd: 'react', 19 | commonjs: 'react', 20 | commonjs2: 'react', 21 | }, 22 | }, 23 | module: { 24 | loaders: [ 25 | { 26 | test: /\.js$/, 27 | loader: 'babel-loader', 28 | exclude: /node_modules/, 29 | query: { 30 | plugins: ['lodash'], 31 | }, 32 | }, 33 | ], 34 | }, 35 | plugins: [ 36 | new LodashModuleReplacementPlugin({ 37 | shorthands: true, 38 | collections: true, 39 | }), 40 | new webpack.optimize.UglifyJsPlugin({ 41 | compressor: { 42 | pure_getters: true, 43 | unsafe: true, 44 | unsafe_comps: true, 45 | screw_ie8: true, 46 | warnings: false, 47 | }, 48 | }), 49 | ], 50 | }; 51 | --------------------------------------------------------------------------------
    41 | {'Foo'} 42 |