├── .gitignore ├── README.md ├── index.html ├── package.json ├── src ├── intent.js ├── intents │ ├── column-sort.js │ ├── container-resize.js │ ├── filter-rows.js │ ├── table-data.js │ └── user-scroll.js ├── main.js ├── model.js ├── models │ ├── make-column-widths.js │ ├── make-filtered-rows.js │ ├── make-sorted-rows.js │ └── make-visible-indices.js ├── view.js └── views │ ├── filter-controls.js │ ├── root.js │ ├── row.js │ ├── tbody.js │ └── thead.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | target 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dynamic-width-filtering-sorting-scroll-table-with-data-loading 2 | 3 | This branch is in turn based off of https://github.com/justinwoo/react-rxjs-flow/tree/dynamic-width-scroll-table, and has more featuers and still ends up combining the multiple features together by combining relevant streams. 4 | 5 | ## Before 6 | 7 | ```js 8 | let columns$ = Rx.Observable.just(['ID (fixed width)', 'ID * 10', 'Random Number']); 9 | let defaultColumnWidths$ = Rx.Observable.just([300, null, null]); 10 | 11 | let tableHeight$ = Rx.Observable.just(500); 12 | let rowHeight$ = Rx.Observable.just(30); 13 | let rowCount$ = Rx.Observable.just(10000); 14 | let scrollTop$ = actions.scrollTop$.startWith(0); 15 | 16 | let visibleIndices$ = makeVisibleIndices$( 17 | tableHeight$, rowHeight$, rowCount$, scrollTop$ 18 | ); 19 | 20 | let containerWidth$ = actions.containerWidth$.startWith(window.innerWidth); 21 | let columnWidths$ = makeColumnWidths$( 22 | defaultColumnWidths$, 23 | containerWidth$ 24 | ); 25 | ``` 26 | 27 | ## After 28 | ```js 29 | let columnSort$ = actions.columnSort$.startWith(defaultColumnSort); 30 | let filterEvenRows$ = actions.filterEvenRows$.startWith(false); 31 | 32 | // Actually load data in 33 | let tableData$ = actions.tableData$.startWith(defaultTableData); 34 | let columns$ = tableData$.map(data => data.columns); 35 | let defaultColumnWidths$ = tableData$.map(data => data.defaultColumnWidths); 36 | 37 | // Row data is mapped from the table data stream 38 | let rawRows$ = tableData$.map(data => data.rows); 39 | 40 | // Sort rows with information about how columns are sorted 41 | let sortedRows$ = makeSortedRows$(rawRows$, columnSort$); 42 | 43 | // Then we take that to filter the rows by criteria 44 | // In this case whether filtering by even-numbered ids or not 45 | let rows$ = makeFilteredRows$(sortedRows$, filterEvenRows$); 46 | let rowCount$ = rows$.map(rows => rows.length); 47 | 48 | let tableHeight$ = Rx.Observable.just(500); 49 | let rowHeight$ = Rx.Observable.just(30); 50 | let scrollTop$ = actions.scrollTop$.startWith(0); 51 | 52 | let visibleIndices$ = makeVisibleIndices$( 53 | tableHeight$, rowHeight$, rowCount$, scrollTop$ 54 | ); 55 | 56 | let containerWidth$ = actions.containerWidth$.startWith(window.innerWidth); 57 | let columnWidths$ = makeColumnWidths$( 58 | defaultColumnWidths$, 59 | containerWidth$ 60 | ); 61 | ``` 62 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamic-width-scroll-table", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack -wdc --progress", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "private": true, 11 | "author": "Justin Woo", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "babel-core": "^5.6.15", 15 | "babel-loader": "^5.3.1", 16 | "node-libs-browser": "^0.5.2", 17 | "react": "^0.13.3", 18 | "rx": "^2.5.3", 19 | "webpack": "^1.10.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/intent.js: -------------------------------------------------------------------------------- 1 | import {scrollTop$} from './intents/user-scroll'; 2 | import {containerWidth$} from './intents/container-resize'; 3 | import {tableData$} from './intents/table-data'; 4 | import {columnSort$} from './intents/column-sort'; 5 | import {filterEvenRows$} from './intents/filter-rows'; 6 | 7 | function intent() { 8 | let actions = { 9 | scrollTop$, 10 | containerWidth$, 11 | tableData$, 12 | columnSort$, 13 | filterEvenRows$ 14 | }; 15 | return actions; 16 | } 17 | 18 | export default intent; 19 | -------------------------------------------------------------------------------- /src/intents/column-sort.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | let columnSortAction = new Rx.Subject(); 4 | 5 | export let getHandleColumnSort = (currentColumn, sortDirection, newColumn) => { 6 | let newSortDirection; 7 | if (newColumn === currentColumn) { 8 | newSortDirection = sortDirection === 1 ? -1 : 1; 9 | } else { 10 | newSortDirection = 1; 11 | } 12 | return () => { 13 | columnSortAction.onNext({ 14 | column: newColumn, 15 | sortDirection: newSortDirection 16 | }); 17 | } 18 | }; 19 | 20 | export let columnSort$ = columnSortAction; 21 | -------------------------------------------------------------------------------- /src/intents/container-resize.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | let containerResizeAction = new Rx.Subject(); 4 | 5 | export let setContainerWidth = value => containerResizeAction.onNext(value); 6 | 7 | export let containerWidth$ = containerResizeAction; 8 | -------------------------------------------------------------------------------- /src/intents/filter-rows.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | let filterEvenRowsAction = new Rx.Subject(); 4 | 5 | export let getHandleFilterEvens = (currentFilterEvens) => { 6 | return () => filterEvenRowsAction.onNext(currentFilterEvens ? false : true); 7 | } 8 | 9 | export let filterEvenRows$ = filterEvenRowsAction; 10 | -------------------------------------------------------------------------------- /src/intents/table-data.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | let tableDataAction = new Rx.Subject(); 4 | 5 | export let setTableData = value => tableDataAction.onNext(value); 6 | 7 | export let tableData$ = tableDataAction; 8 | -------------------------------------------------------------------------------- /src/intents/user-scroll.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | let scrollAction = new Rx.Subject(); 4 | 5 | export let handleOnScroll = e => scrollAction.onNext(e); 6 | 7 | export let scrollTop$ = scrollAction.map( 8 | e => e.target.scrollTop 9 | ); 10 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import intent from './intent'; 4 | import model from './model'; 5 | import view from './view'; 6 | 7 | function main() { 8 | let actions = intent(); 9 | let state$ = model(actions); 10 | let output$ = view(state$); 11 | 12 | return output$; 13 | } 14 | 15 | let target = document.getElementById('app'); 16 | 17 | main().subscribe(Output => React.render(Output, target)); 18 | 19 | // load up fixture data 20 | let fixtureData = { 21 | columns: ['ID (fixed width)', 'ID * 10', 'Random Number'], 22 | defaultColumnWidths: [300, null, null], 23 | rows: [] 24 | }; 25 | 26 | for (let i = 0; i < 10000; i++) { 27 | fixtureData.rows.push([ 28 | i, 29 | i * 10, 30 | Math.floor(Math.random() * 10000) 31 | ]); 32 | } 33 | 34 | import {tableData$} from './intents/table-data'; 35 | 36 | tableData$.onNext(fixtureData); 37 | -------------------------------------------------------------------------------- /src/model.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | import makeVisibleIndices$ from './models/make-visible-indices'; 4 | import makeColumnWidths$ from './models/make-column-widths'; 5 | import makeSortedRows$ from './models/make-sorted-rows'; 6 | import makeFilteredRows$ from './models/make-filtered-rows'; 7 | 8 | let defaultTableData = { 9 | columns: [], 10 | defaultColumnWidths: [], 11 | rows: [] 12 | }; 13 | 14 | let defaultColumnSort = { 15 | column: null, 16 | sortDirection: null 17 | }; 18 | 19 | function model(actions) { 20 | let columnSort$ = actions.columnSort$.startWith(defaultColumnSort); 21 | let filterEvenRows$ = actions.filterEvenRows$.startWith(false); 22 | 23 | let tableData$ = actions.tableData$.startWith(defaultTableData); 24 | let columns$ = tableData$.map(data => data.columns); 25 | let defaultColumnWidths$ = tableData$.map(data => data.defaultColumnWidths); 26 | let rawRows$ = tableData$.map(data => data.rows); 27 | 28 | let sortedRows$ = makeSortedRows$(rawRows$, columnSort$); 29 | 30 | let rows$ = makeFilteredRows$(sortedRows$, filterEvenRows$); 31 | let rowCount$ = rows$.map(rows => rows.length); 32 | 33 | let tableHeight$ = Rx.Observable.just(500); 34 | let rowHeight$ = Rx.Observable.just(30); 35 | let scrollTop$ = actions.scrollTop$.startWith(0); 36 | 37 | let visibleIndices$ = makeVisibleIndices$( 38 | tableHeight$, rowHeight$, rowCount$, scrollTop$ 39 | ); 40 | 41 | let containerWidth$ = actions.containerWidth$.startWith(window.innerWidth); 42 | let columnWidths$ = makeColumnWidths$( 43 | defaultColumnWidths$, 44 | containerWidth$ 45 | ); 46 | 47 | return Rx.Observable.combineLatest( 48 | tableHeight$, 49 | rowHeight$, 50 | columns$, 51 | rowCount$, 52 | visibleIndices$, 53 | columnWidths$, 54 | rows$, 55 | columnSort$, 56 | filterEvenRows$, 57 | ( 58 | tableHeight, 59 | rowHeight, 60 | columns, 61 | rowCount, 62 | visibleIndices, 63 | columnWidths, 64 | rows, 65 | columnSort, 66 | filterEvenRows 67 | ) => ({ 68 | tableHeight, 69 | rowHeight, 70 | columns, 71 | rowCount, 72 | visibleIndices, 73 | columnWidths, 74 | rows, 75 | columnSort, 76 | filterEvenRows 77 | }) 78 | ); 79 | } 80 | 81 | export default model; 82 | -------------------------------------------------------------------------------- /src/models/make-column-widths.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | function makeColumnWidths$(defaultColumnWidths$, containerWidth$) { 4 | return Rx.Observable.combineLatest( 5 | defaultColumnWidths$, containerWidth$, 6 | (defaultColumnWidths, containerWidth) => { 7 | let computation = defaultColumnWidths.reduce(function (agg, width) { 8 | if (typeof width === 'number') { 9 | agg.remainingWidth -= width; 10 | agg.autoSizeColumns -= 1; 11 | } 12 | return agg; 13 | }, { 14 | autoSizeColumns: defaultColumnWidths.length, 15 | remainingWidth: containerWidth 16 | }); 17 | 18 | let standardWidth = computation.remainingWidth / computation.autoSizeColumns; 19 | 20 | return defaultColumnWidths.map(function (width) { 21 | if (width) { 22 | return width; 23 | } else { 24 | return standardWidth; 25 | } 26 | }); 27 | } 28 | ); 29 | } 30 | 31 | export default makeColumnWidths$; 32 | -------------------------------------------------------------------------------- /src/models/make-filtered-rows.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | function makeFilteredRows$(rows$, filterEvenRows$) { 4 | return Rx.Observable.combineLatest( 5 | rows$, 6 | filterEvenRows$, 7 | (rows, filterEvenRows) => { 8 | if (filterEvenRows) { 9 | return rows.reduce((agg, row) => { 10 | if (row[0] % 2 !== 0) { 11 | agg.push(row); 12 | } 13 | return agg; 14 | }, []); 15 | } else { 16 | return rows; 17 | } 18 | } 19 | ); 20 | } 21 | 22 | export default makeFilteredRows$; 23 | -------------------------------------------------------------------------------- /src/models/make-sorted-rows.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | function makeSortedRows(rawRows$, columnSort$) { 4 | return Rx.Observable.combineLatest( 5 | rawRows$, 6 | columnSort$, 7 | (rawRows, columnSort) => { 8 | if (columnSort.column === null) { 9 | return rawRows; 10 | } else { 11 | let {sortDirection, column} = columnSort; 12 | let newRows = rawRows.map(x => x); 13 | newRows.sort((left, right) => { 14 | let a = left[column]; 15 | let b = right[column]; 16 | let sortResult; 17 | if (a < b) { 18 | sortResult = -1; 19 | } else if (a > b) { 20 | sortResult = 1; 21 | } else { 22 | sortResult = 0; 23 | } 24 | return sortDirection === 1 ? sortResult : -1 * sortResult; 25 | }); 26 | 27 | return newRows; 28 | } 29 | } 30 | ); 31 | } 32 | 33 | export default makeSortedRows; 34 | -------------------------------------------------------------------------------- /src/models/make-visible-indices.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | 3 | function makeVisibleIndices$(tableHeight$, rowHeight$, rowCount$, scrollTop$) { 4 | let firstVisibleRow$ = Rx.Observable.combineLatest(scrollTop$, rowHeight$, 5 | (scrollTop, rowHeight) => Math.floor(scrollTop / rowHeight) 6 | ).distinctUntilChanged(); 7 | 8 | let visibleRows$ = Rx.Observable.combineLatest(tableHeight$, rowHeight$, 9 | (tableHeight, rowHeight) => Math.ceil(tableHeight / rowHeight) 10 | ); 11 | 12 | let visibleIndices$ = Rx.Observable.combineLatest( 13 | rowCount$, visibleRows$, firstVisibleRow$, 14 | (rowCount, visibleRows, firstVisibleRow) => { 15 | let visibleIndices = []; 16 | let lastRow = firstVisibleRow + visibleRows + 1; 17 | 18 | if (lastRow > rowCount) { 19 | firstVisibleRow -= lastRow - rowCount; 20 | } 21 | 22 | for (let i = 0; i <= visibleRows; i++) { 23 | visibleIndices.push(i + firstVisibleRow); 24 | } 25 | return visibleIndices; 26 | } 27 | ); 28 | 29 | return visibleIndices$; 30 | } 31 | 32 | export default makeVisibleIndices$; 33 | -------------------------------------------------------------------------------- /src/view.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Root from './views/root'; 4 | 5 | function view(state$) { 6 | return state$.map(state => ); 7 | } 8 | 9 | export default view; 10 | -------------------------------------------------------------------------------- /src/views/filter-controls.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PureRenderMixin from 'react/lib/ReactComponentWithPureRenderMixin'; 3 | 4 | import { 5 | getHandleFilterEvens 6 | } from '../intents/filter-rows'; 7 | 8 | let FilterControls = React.createClass({ 9 | mixins: [PureRenderMixin], 10 | 11 | render: function () { 12 | let { 13 | filterEvenRows 14 | } = this.props; 15 | 16 | return ( 17 |
18 | 24 |
25 | ); 26 | } 27 | }); 28 | 29 | export default FilterControls; 30 | -------------------------------------------------------------------------------- /src/views/root.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {handleOnScroll} from '../intents/user-scroll'; 4 | import {setContainerWidth} from '../intents/container-resize'; 5 | 6 | import THead from './thead'; 7 | import TBody from './tbody'; 8 | import FilterControls from './filter-controls'; 9 | 10 | let Root = React.createClass({ 11 | render: function () { 12 | let { 13 | tableHeight, 14 | rowHeight, 15 | columns, 16 | rowCount, 17 | visibleIndices, 18 | columnWidths, 19 | rows, 20 | columnSort, 21 | filterEvenRows 22 | } = this.props; 23 | 24 | let staticHeaderTableStyle = { 25 | overflowX: 'hidden', 26 | borderBottom: '1px solid black', 27 | width: '100%' 28 | }; 29 | 30 | let scrollTableContainerStyle = { 31 | position: 'relative', 32 | overflowX: 'hidden', 33 | borderBottom: '1px solid black', 34 | height: tableHeight + 'px', 35 | }; 36 | 37 | return ( 38 |
39 | 40 |
41 | 43 | 44 |
45 |
46 |
48 | 49 | 50 |
51 |
52 |
53 | ); 54 | }, 55 | 56 | componentDidMount: function () { 57 | let scrollTableContainerNode = React.findDOMNode(this.refs.ScrollTableContainer); 58 | let getScrollTableContainerNodeWidth = () => scrollTableContainerNode.offsetWidth; 59 | 60 | Rx.Observable.fromEvent(window, 'resize') 61 | .map(getScrollTableContainerNodeWidth) 62 | .debounce(50) 63 | .distinctUntilChanged() 64 | .startWith(getScrollTableContainerNodeWidth()) 65 | .subscribe(value => setContainerWidth(value)); 66 | } 67 | }); 68 | 69 | export default Root; 70 | -------------------------------------------------------------------------------- /src/views/row.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PureRenderMixin from 'react/lib/ReactComponentWithPureRenderMixin'; 3 | 4 | let Row = React.createClass({ 5 | mixins: [PureRenderMixin], 6 | 7 | render: function () { 8 | let { 9 | index, 10 | rowHeight, 11 | columnWidths, 12 | row 13 | } = this.props; 14 | 15 | let top = index * rowHeight; 16 | let trStyle = { 17 | position: 'absolute', 18 | top: top + 'px', 19 | width: '100%', 20 | borderBottom: '1px solid grey' 21 | }; 22 | 23 | let nodes; 24 | if (row === undefined) { 25 | nodes = null; 26 | } else { 27 | nodes = columnWidths.map((width, i) => { 28 | return ( 29 | 30 | {String(row[i])} 31 | 32 | ); 33 | }); 34 | } 35 | 36 | return ( 37 | 38 | {nodes} 39 | 40 | ); 41 | } 42 | }); 43 | 44 | export default Row; 45 | -------------------------------------------------------------------------------- /src/views/tbody.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PureRenderMixin from 'react/lib/ReactComponentWithPureRenderMixin'; 3 | 4 | import Row from './row'; 5 | 6 | let TBody = React.createClass({ 7 | mixins: [PureRenderMixin], 8 | 9 | render: function () { 10 | let { 11 | rowHeight, 12 | visibleIndices, 13 | columnWidths, 14 | rows 15 | } = this.props; 16 | 17 | let nodes = visibleIndices.map((index, i) => { 18 | let rowProps = { 19 | index, 20 | rowHeight, 21 | columnWidths, 22 | row: rows[index] 23 | }; 24 | let renderKey = index % visibleIndices.length; 25 | return ( 26 | 30 | ); 31 | }); 32 | 33 | return ( 34 | 35 | {nodes} 36 | 37 | ); 38 | } 39 | }); 40 | 41 | export default TBody; 42 | -------------------------------------------------------------------------------- /src/views/thead.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PureRenderMixin from 'react/lib/ReactComponentWithPureRenderMixin'; 3 | 4 | import {getHandleColumnSort} from '../intents/column-sort'; 5 | 6 | let THead = React.createClass({ 7 | mixins: [PureRenderMixin], 8 | 9 | render: function () { 10 | let { 11 | columns, 12 | columnWidths, 13 | columnSort 14 | } = this.props; 15 | 16 | let { 17 | column: currentColumn, 18 | sortDirection 19 | } = columnSort; 20 | 21 | let nodes = columns.map((value, i) => { 22 | let thStyle = {width: columnWidths[i] + 'px', cursor: 'pointer'}; 23 | let sortArrow = ''; 24 | if (currentColumn === i) { 25 | if (sortDirection === 1) { 26 | sortArrow = '↑'; 27 | } else { 28 | sortArrow = '↓'; 29 | } 30 | } 31 | return ( 32 | 34 | {value + ' ' + sortArrow} 35 | 36 | ); 37 | }); 38 | return ( 39 | 40 | 41 | {nodes} 42 | 43 | 44 | ); 45 | } 46 | }); 47 | 48 | export default THead; 49 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | 5 | entry: [ 6 | './src/main' 7 | ], 8 | 9 | output: { 10 | path: path.join(__dirname, 'target'), 11 | filename: 'main.js' 12 | }, 13 | 14 | module: { 15 | loaders: [{ 16 | test: function (filename) { 17 | if (filename.indexOf('node_modules') !== -1) { 18 | return false; 19 | } else { 20 | return /\.js$/.test(filename) !== -1; 21 | } 22 | }, 23 | loaders: ['babel-loader'] 24 | }] 25 | }, 26 | 27 | resolve: { 28 | modulesDirectories: [path.join(__dirname, 'src'), 'node_modules'] 29 | } 30 | 31 | }; 32 | --------------------------------------------------------------------------------