├── .babelrc
├── .editorconfig
├── .gitignore
├── README.md
├── app
├── actions
│ └── actions.js
├── app.css
├── app.html
├── app.jsx
├── components
│ ├── data-table.jsx
│ ├── data-table.test.js
│ ├── page-navigator.jsx
│ ├── page-navigator.test.js
│ └── search-bar.jsx
├── containers
│ └── app.jsx
└── reducers
│ ├── current-page.js
│ ├── filter-text.js
│ ├── item-properties.js
│ ├── items-per-page.js
│ ├── items.js
│ ├── sorting-property.js
│ └── state.js
├── dev-server.js
├── jsconfig.json
├── package.json
├── webpack.config.dev.js
└── webpack.config.prod.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react", "es2015"],
3 | "env": {
4 | "development": {
5 | "plugins": [
6 | ["react-transform", {
7 | "transforms": [{
8 | "transform": "react-transform-hmr",
9 | "imports": ["react"],
10 | "locals": ["module"]
11 | }, {
12 | "transform": "react-transform-catch-errors",
13 | "imports": ["react", "redbox-react"]
14 | }]
15 | }]
16 | ]
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | end_of_line = lf
3 | insert_final_newline = true
4 |
5 | indent_style = space
6 | indent_size = 2
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | www
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### Overview
2 |
3 | A simple data table with item filtering, sorting, pagination, and removal. Built with React, Redux and ImmutableJS. Demo: http://lewisl9029.github.io/redux-data-table/
4 |
5 | Note: This is only a demo app that I built over the weekend as a part of the interview process at a company I was applying to. It's not yet ready to be used as an external component (I'll take some time to clean this up and publish on npm in the near future).
6 |
--------------------------------------------------------------------------------
/app/actions/actions.js:
--------------------------------------------------------------------------------
1 | import { Seq, Map, OrderedMap } from 'immutable';
2 |
3 | export const UPDATE_FILTER = 'UPDATE_FILTER';
4 | export function updateFilter(filterText) {
5 | return { type: UPDATE_FILTER, filterText };
6 | }
7 |
8 | export const SWITCH_PAGE = 'SWITCH_PAGE';
9 | export function switchPage(page) {
10 | return { type: SWITCH_PAGE, page };
11 | }
12 |
13 | export const SORT_ITEMS = 'SORT_ITEMS';
14 | export function sortItems(sortingProperty) {
15 | return { type: SORT_ITEMS, sortingProperty };
16 | }
17 |
18 | export const REMOVE_ITEM = 'REMOVE_ITEM';
19 | export function removeItem(itemId) {
20 | return { type: REMOVE_ITEM, itemId };
21 | }
22 |
23 | export const REVERSE_ITEMS = 'REVERSE_ITEMS';
24 | export function reverseItems() {
25 | return { type: REVERSE_ITEMS };
26 | }
27 |
28 | export const UPDATE_ITEMS = 'UPDATE_ITEMS';
29 | export function updateItems(items) {
30 | return { type: UPDATE_ITEMS, items };
31 | }
--------------------------------------------------------------------------------
/app/app.css:
--------------------------------------------------------------------------------
1 | @import url(https://fonts.googleapis.com/css?family=Raleway:400,300,600);
2 |
3 | button:disabled {
4 | opacity: 0.5;
5 | }
6 |
7 | #ribbon-container {
8 | pointer-events: none;
9 | position: absolute;
10 | top: 0;
11 | right: 0;
12 | overflow: hidden;
13 | height: 190px;
14 | width: 190px;
15 | }
16 |
17 | #ribbon {
18 | pointer-events: all;
19 | z-index: 2;
20 | position: relative;
21 | right: -30px;
22 | top: 30px;
23 | white-space: nowrap;
24 | transform: rotate(45deg);
25 | }
26 |
27 | #ribbon a {
28 | padding: 0 40px;
29 | background-color: white;
30 | }
31 |
32 |
--------------------------------------------------------------------------------
/app/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {%=o.htmlWebpackPlugin.options.title || 'Webpack App'%}
6 | {% if (o.htmlWebpackPlugin.files.favicon) { %}
7 |
8 | {% } %}
9 | {% for (var css in o.htmlWebpackPlugin.files.css) { %}
10 |
11 | {% } %}
12 |
13 |
14 |
22 |
23 | {% for (var chunk in o.htmlWebpackPlugin.files.chunks) { %}
24 |
25 | {% } %}
26 |
27 |
--------------------------------------------------------------------------------
/app/app.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import 'normalize.css';
4 | import 'skeleton-css-webpack';
5 | import 'babel-polyfill';
6 | import React from 'react';
7 | import { render } from 'react-dom';
8 | import { Provider } from 'react-redux';
9 | import { createStore, applyMiddleware } from 'redux';
10 | import createLogger from 'redux-logger';
11 | import thunkMiddleware from 'redux-thunk';
12 |
13 | import './app.css';
14 | import state from './reducers/state';
15 | import App from './containers/app';
16 |
17 | const loggerMiddleware = createLogger();
18 |
19 | const createStoreWithMiddleware = applyMiddleware(
20 | thunkMiddleware,
21 | loggerMiddleware
22 | )(createStore);
23 |
24 | let store = createStoreWithMiddleware(state);
25 |
26 | let app = document.getElementById('app');
27 |
28 | render(
29 |
30 |
31 | ,
32 | app
33 | );
--------------------------------------------------------------------------------
/app/components/data-table.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import SearchBar from './search-bar';
4 | import PageNavigator from './page-navigator';
5 |
6 | export function isPartialMatch(text, filter) {
7 | return text.toLowerCase().indexOf(filter.toLowerCase()) !== -1;
8 | }
9 |
10 | export function getBeginningIndex(currentPage, itemsPerPage) {
11 | return (currentPage - 1) * itemsPerPage;
12 | }
13 |
14 | export function getEndingIndex(currentPage, itemsPerPage, numberOfItems) {
15 | return Math.min(currentPage * itemsPerPage, numberOfItems);
16 | }
17 |
18 | let DataTable = ({
19 | currentPage,
20 | filterText,
21 | itemsPerPage,
22 | items,
23 | itemProperties,
24 | removeItem,
25 | reverseItems,
26 | sortItems,
27 | sortingProperty,
28 | switchPage,
29 | updateFilter
30 | }) => {
31 | //TODO: use reselect's memoized selectors for filtering and pagination
32 | let filteredItems = items
33 | //TODO: refactor to allow filtering by all parameters
34 | .filter(item => isPartialMatch(item.get('name'), filterText));
35 |
36 | let beginningIndex = getBeginningIndex(currentPage, itemsPerPage);
37 | let endingIndex = getEndingIndex(currentPage, itemsPerPage, filteredItems.size);
38 |
39 | let visibleItems = filteredItems.slice(beginningIndex, endingIndex);
40 |
41 | return (
42 | 0 ?
44 | { display: 'block' } :
45 | { display: 'none' }
46 | }>
47 |
48 |
50 |
51 |
56 |
57 |
58 |
61 |
64 |
67 |
70 |
71 |
72 |
73 | {
74 | itemProperties.valueSeq().map(itemProperty => (
75 | sortingProperty !== itemProperty.get('id') ?
84 | sortItems(itemProperty.get('id')) :
85 | reverseItems()
86 | }
87 | >
88 | {itemProperty.get('description')}
89 | |
90 | ))
91 | }
92 | |
93 |
94 |
95 |
96 | {
97 | visibleItems.valueSeq().map(item => (
98 |
99 | {
100 | itemProperties.valueSeq().map(itemProperty => (
101 |
102 | {item.get(itemProperty.get('id'))}
103 | |
104 | ))
105 | }
106 |
109 |
125 | |
126 |
127 | ))
128 | }
129 |
130 |
131 |
132 | );
133 | };
134 |
135 | export default DataTable;
136 |
--------------------------------------------------------------------------------
/app/components/data-table.test.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { isPartialMatch, getBeginningIndex, getEndingIndex } from './data-table';
3 |
4 | describe('table filtering function', () => {
5 | it('should be able to match partial strings', () => {
6 | expect(
7 | isPartialMatch('haystack needle haystack', 'needle ')
8 | ).to.be.true;
9 | });
10 |
11 | it('should be able to match any string against empty string filter', () => {
12 | expect(
13 | isPartialMatch('haystack needle haystack', '')
14 | ).to.be.true;
15 |
16 | expect(
17 | isPartialMatch('', '')
18 | ).to.be.true;
19 | });
20 |
21 | it('should be able to match partial strings case-insensitively', () => {
22 | expect(
23 | isPartialMatch('haystack needle haystack', 'NEEDLE')
24 | ).to.be.true;
25 |
26 | expect(
27 | isPartialMatch('haystack NEEDLE haystack', 'needle')
28 | ).to.be.true;
29 | });
30 | });
31 |
32 | describe('table data indexing functions', () => {
33 | it('should be correct for regular inputs', () => {
34 | expect(
35 | getBeginningIndex(1, 5)
36 | ).to.equal(0);
37 |
38 | expect(
39 | getBeginningIndex(5, 5)
40 | ).to.equal(20);
41 |
42 | expect(
43 | getEndingIndex(1, 10, 30)
44 | ).to.equal(10);
45 |
46 | expect(
47 | getEndingIndex(3, 10, 30)
48 | ).to.equal(30);
49 | });
50 |
51 | it('should be correct for extreme inputs', () => {
52 | expect(
53 | getEndingIndex(4, 10, 35)
54 | ).to.equal(35);
55 | });
56 | });
--------------------------------------------------------------------------------
/app/components/page-navigator.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Map, Range } from 'immutable';
3 |
4 | export function getNumberOfPages(numberOfItems, itemsPerPage) {
5 | // Ensure there is at least 1 page even when there are 0 visible items
6 | return Math.max(1, Math.ceil(numberOfItems / itemsPerPage));
7 | }
8 |
9 | let PageNavigator = ({itemsPerPage, filteredItems, currentPage, switchPage}) => {
10 | let numberOfPages = getNumberOfPages(filteredItems.size, itemsPerPage);
11 | // Range is inclusive for begin, non-inclusive for end
12 | let pages = Range(1, numberOfPages + 1);
13 | return (
14 |
15 |
16 |
27 |
28 |
29 |
45 |
46 |
47 |
58 |
59 |
60 | );
61 | };
62 |
63 | export default PageNavigator;
--------------------------------------------------------------------------------
/app/components/page-navigator.test.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { getNumberOfPages } from './page-navigator';
3 |
4 | describe('page navigator max page number calculation', () => {
5 | it('should be correct for regular values', () => {
6 | expect(
7 | getNumberOfPages(20, 10)
8 | ).to.equal(2);
9 |
10 | expect(
11 | getNumberOfPages(21, 10)
12 | ).to.equal(3);
13 | });
14 |
15 | it('should be return at least 1 page', () => {
16 | expect(
17 | getNumberOfPages(8, 10)
18 | ).to.equal(1);
19 |
20 | expect(
21 | getNumberOfPages(0, 10)
22 | ).to.equal(1);
23 | });
24 | });
--------------------------------------------------------------------------------
/app/components/search-bar.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | let SearchBar = ({ updateFilter }) => (
4 |
5 |
6 | updateFilter(event.target.value)}
14 | />
15 |
16 |
17 | );
18 |
19 | export default SearchBar;
--------------------------------------------------------------------------------
/app/containers/app.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'react-dom';
3 | import { connect } from 'react-redux';
4 |
5 | import * as actions from '../actions/actions';
6 | import DataTable from '../components/data-table';
7 |
8 | let App = ({
9 | dispatch,
10 | currentPage,
11 | filename,
12 | filterText,
13 | itemsPerPage,
14 | items,
15 | itemProperties,
16 | sortingProperty
17 | }) => (
18 |
21 |
Redux Data Table Demo
22 | dispatch(actions.removeItem(itemId))}
29 | reverseItems={() => dispatch(actions.reverseItems())}
30 | sortingProperty={sortingProperty}
31 | sortItems={sortingProperty => dispatch(actions.sortItems(sortingProperty))}
32 | switchPage={page => dispatch(actions.switchPage(page))}
33 | updateFilter={filterText => dispatch(actions.updateFilter(filterText))} />
34 |
35 | );
36 |
37 | export default connect(state => ({
38 | currentPage: state.get('currentPage'),
39 | filterText: state.get('filterText'),
40 | itemProperties: state.get('itemProperties'),
41 | items: state.get('items'),
42 | itemsPerPage: state.get('itemsPerPage'),
43 | sortingProperty: state.get('sortingProperty')
44 | }))(App);
--------------------------------------------------------------------------------
/app/reducers/current-page.js:
--------------------------------------------------------------------------------
1 | import { SWITCH_PAGE, UPDATE_FILTER, UPDATE_ITEMS } from '../actions/actions';
2 |
3 | let initialState = 1;
4 |
5 | let currentPage = (state = initialState, action) => {
6 | switch (action.type) {
7 | case SWITCH_PAGE:
8 | return action.page;
9 | // Reset to first page when filtering to avoid out of bounds pages
10 | case UPDATE_FILTER:
11 | return initialState;
12 | // Reset to first page when updating data to avoid out of bounds pages
13 | case UPDATE_ITEMS:
14 | return initialState;
15 | default:
16 | return state;
17 | }
18 | };
19 |
20 | export default currentPage;
--------------------------------------------------------------------------------
/app/reducers/filter-text.js:
--------------------------------------------------------------------------------
1 | import { UPDATE_FILTER } from '../actions/actions';
2 |
3 | let initialState = '';
4 |
5 | let filterText = (state = initialState, action) => {
6 | switch (action.type) {
7 | case UPDATE_FILTER:
8 | return action.filterText;
9 | default:
10 | return state;
11 | }
12 | };
13 |
14 | export default filterText;
--------------------------------------------------------------------------------
/app/reducers/item-properties.js:
--------------------------------------------------------------------------------
1 | import { Map, OrderedMap } from 'immutable';
2 |
3 | //TODO: refactor as props on the exported component for external use
4 | let initialState = OrderedMap({
5 | 'name': Map({ id: 'name', description: 'Name'}),
6 | 'job': Map({ id: 'job', description: 'Job Title'}),
7 | 'salary': Map({ id: 'salary', description: 'Salary'})
8 | });
9 |
10 | let itemProperties = (state = initialState, action) => {
11 | switch (action.type) {
12 | default:
13 | return state;
14 | }
15 | };
16 |
17 | export default itemProperties;
--------------------------------------------------------------------------------
/app/reducers/items-per-page.js:
--------------------------------------------------------------------------------
1 | //TODO: allow this to be customized
2 | let initialState = 10;
3 |
4 | let itemsPerPage = (state = initialState, action) => {
5 | switch (action.type) {
6 | default:
7 | return state;
8 | }
9 | };
10 |
11 | export default itemsPerPage;
--------------------------------------------------------------------------------
/app/reducers/items.js:
--------------------------------------------------------------------------------
1 | import { Range, Map, OrderedMap } from 'immutable';
2 | import faker from 'faker';
3 |
4 | import {
5 | SORT_ITEMS,
6 | REMOVE_ITEM,
7 | REVERSE_ITEMS,
8 | UPDATE_ITEMS
9 | } from '../actions/actions';
10 |
11 | //TODO: refactor as props on the exported component for external use
12 | let fakeItems = Range(0, 1000)
13 | .map(id => [id, Map({
14 | id,
15 | name: faker.name.findName(),
16 | job: faker.name.jobTitle(),
17 | salary: faker.random.number()
18 | })]);
19 |
20 | let initialState = OrderedMap(fakeItems);
21 |
22 | let items = (state = initialState, action) => {
23 | switch (action.type) {
24 | case SORT_ITEMS:
25 | return state.sortBy(item => item.get(action.sortingProperty));
26 | case REVERSE_ITEMS:
27 | return state.reverse();
28 | case REMOVE_ITEM:
29 | return state.remove(action.itemId);
30 | //TODO: support updates to individual items eventually
31 | case UPDATE_ITEMS:
32 | return action.items;
33 | default:
34 | return state;
35 | }
36 | };
37 |
38 | export default items;
--------------------------------------------------------------------------------
/app/reducers/sorting-property.js:
--------------------------------------------------------------------------------
1 | import { SORT_ITEMS } from '../actions/actions';
2 |
3 | let initialState = 'id';
4 |
5 | let sortingProperty = (state = initialState, action) => {
6 | switch (action.type) {
7 | case SORT_ITEMS:
8 | return action.sortingProperty;
9 | default:
10 | return state;
11 | }
12 | };
13 |
14 | export default sortingProperty;
--------------------------------------------------------------------------------
/app/reducers/state.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux-immutablejs';
2 | import { Map } from 'immutable';
3 |
4 | import currentPage from './current-page';
5 | import filterText from './filter-text';
6 | import itemProperties from './item-properties';
7 | import items from './items';
8 | import itemsPerPage from './items-per-page';
9 | import sortingProperty from './sorting-property';
10 |
11 | export default combineReducers({
12 | currentPage,
13 | filterText,
14 | itemProperties,
15 | items,
16 | itemsPerPage,
17 | sortingProperty
18 | });
--------------------------------------------------------------------------------
/dev-server.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 | var express = require('express');
3 | var webpack = require('webpack');
4 | var config = require('./webpack.config.dev');
5 |
6 | var app = express();
7 | var compiler = webpack(config);
8 |
9 | app.use(require('webpack-dev-middleware')(compiler, {
10 | noInfo: true,
11 | publicPath: config.output.publicPath
12 | }));
13 |
14 | app.use(require('webpack-hot-middleware')(compiler));
15 |
16 | app.get('*', function(req, res) {
17 | res.sendFile(path.join(__dirname, 'app/app.html'));
18 | });
19 |
20 | app.listen(8080, 'localhost', function(err) {
21 | if (err) {
22 | console.log(err);
23 | return;
24 | }
25 |
26 | console.log('Listening at http://localhost:8080');
27 | });
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "compilerOptions": {
4 | "target": "ES6",
5 | "module": "commonjs"
6 | }
7 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-data-table",
3 | "version": "0.0.1",
4 | "description": "Simple React + Redux + ImmutableJS data table with item filtering, sorting, pagination and removal.",
5 | "main": "app/app.jsx",
6 | "scripts": {
7 | "build": "node_modules/.bin/webpack --config webpack.config.prod.js",
8 | "start": "node dev-server.js",
9 | "test": "node_modules/.bin/mocha --reporter spec --compilers js:babel-core/register app/**/*.test.js",
10 | "test:watch": "npm test -- --watch"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/lewisl9029/redux-data-table.git"
15 | },
16 | "author": "Lewis Liu",
17 | "license": "MIT",
18 | "bugs": {
19 | "url": "https://github.com/lewisl9029/redux-data-table/issues"
20 | },
21 | "homepage": "https://github.com/lewisl9029/redux-data-table#readme",
22 | "dependencies": {
23 | "babel-polyfill": "^6.3.14",
24 | "faker": "^3.0.1",
25 | "immutable": "^3.7.5",
26 | "normalize.css": "^3.0.3",
27 | "react": "^0.14.3",
28 | "react-dom": "^0.14.3",
29 | "react-redux": "^4.0.0",
30 | "redux": "^3.0.4",
31 | "redux-immutablejs": "0.0.7",
32 | "redux-logger": "^2.2.1",
33 | "redux-thunk": "^1.0.0",
34 | "skeleton-css-webpack": "^2.0.4"
35 | },
36 | "devDependencies": {
37 | "babel-core": "^6.3.17",
38 | "babel-loader": "^6.2.0",
39 | "babel-plugin-react-transform": "^2.0.0-beta1",
40 | "babel-preset-es2015": "^6.3.13",
41 | "babel-preset-react": "^6.3.13",
42 | "chai": "^3.4.1",
43 | "chai-immutable": "^1.5.3",
44 | "css-loader": "^0.23.0",
45 | "express": "^4.13.3",
46 | "extract-text-webpack-plugin": "^0.9.1",
47 | "html-webpack-plugin": "^1.7.0",
48 | "mocha": "^2.3.4",
49 | "react-transform-catch-errors": "^1.0.0",
50 | "react-transform-hmr": "^1.0.1",
51 | "redbox-react": "^1.2.0",
52 | "style-loader": "^0.13.0",
53 | "webpack": "^1.12.9",
54 | "webpack-dev-middleware": "^1.4.0",
55 | "webpack-hot-middleware": "^2.6.0"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | // Hot reloading doesn't work right now due to the fact that
3 | // React v0.14's stateless components [1] aren't supported yet [2]
4 | // [1] https://facebook.github.io/react/blog/2015/10/07/react-v0.14.html#stateless-functional-components
5 | // [2] https://github.com/gaearon/babel-plugin-react-transform/issues/57
6 | let path = require('path');
7 | let webpack = require('webpack');
8 | let HtmlWebpackPlugin = require('html-webpack-plugin');
9 | let ExtractTextPlugin = require('extract-text-webpack-plugin');
10 |
11 | module.exports = {
12 | devtool: 'source-map',
13 | entry: [
14 | 'webpack-hot-middleware/client',
15 | './app/app'
16 | ],
17 | module: {
18 | loaders: [{
19 | test: /\.jsx?$/,
20 | include: path.join(__dirname, 'app'),
21 | loaders: [
22 | 'babel'
23 | ]
24 | }, {
25 | test: /\.css$/,
26 | loader: ExtractTextPlugin.extract("style-loader", "css-loader")
27 | }]
28 | },
29 | resolve: {
30 | extensions: ['', '.js', '.jsx']
31 | },
32 | output: {
33 | path: path.join(__dirname, 'www'),
34 | filename: 'app.js'
35 | },
36 | plugins: [
37 | new webpack.HotModuleReplacementPlugin(),
38 | new webpack.NoErrorsPlugin(),
39 | new HtmlWebpackPlugin({
40 | title: 'Redux Data Table Demo',
41 | template: './app/app.html'
42 | }),
43 | new ExtractTextPlugin("[name].css")
44 | ]
45 | };
--------------------------------------------------------------------------------
/webpack.config.prod.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | let path = require('path');
4 | let webpack = require('webpack');
5 | let HtmlWebpackPlugin = require('html-webpack-plugin');
6 | let ExtractTextPlugin = require('extract-text-webpack-plugin');
7 |
8 | module.exports = {
9 | devtool: 'source-map',
10 | entry: [
11 | './app/app'
12 | ],
13 | module: {
14 | loaders: [{
15 | test: /\.jsx?$/,
16 | include: path.join(__dirname, 'app'),
17 | loaders: [
18 | 'babel'
19 | ]
20 | }, {
21 | test: /\.css$/,
22 | loader: ExtractTextPlugin.extract("style-loader", "css-loader")
23 | }]
24 | },
25 | resolve: {
26 | extensions: ['', '.js', '.jsx']
27 | },
28 | output: {
29 | path: path.resolve(__dirname, 'www'),
30 | filename: 'app.js'
31 | },
32 | plugins: [
33 | new webpack.optimize.OccurenceOrderPlugin(),
34 | new webpack.optimize.UglifyJsPlugin({
35 | compressor: {
36 | warnings: false
37 | }
38 | }),
39 | new HtmlWebpackPlugin({
40 | title: 'Redux Data Table Demo',
41 | template: './app/app.html'
42 | }),
43 | new ExtractTextPlugin("[name].css")
44 | ]
45 | };
--------------------------------------------------------------------------------