├── src ├── setupTests.js ├── contants.js ├── renderers │ ├── index.js │ ├── Deletable.js │ ├── Favorite.js │ ├── Expandable.js │ └── __tests__ │ │ ├── Deletable.test.js │ │ ├── Favorite.test.js │ │ └── Expandable.test.js ├── filtering │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── DefaultGroupRenderer.test.js.snap │ │ └── DefaultGroupRenderer.test.js │ └── DefaultGroupRenderer.js ├── shapes │ ├── rendererShapes.js │ └── nodeShapes.js ├── index.js ├── selectors │ ├── __tests__ │ │ ├── getFlattenedTree.test.js │ │ ├── filtering.test.js │ │ ├── __snapshots__ │ │ │ ├── filtering.test.js.snap │ │ │ ├── getFlattenedTree.test.js.snap │ │ │ └── nodes.test.js.snap │ │ └── nodes.test.js │ ├── filtering.js │ ├── getFlattenedTree.js │ └── nodes.js ├── __tests__ │ ├── eventWrappers.test.js │ ├── Tree │ │ ├── render.test.js │ │ ├── __snapshots__ │ │ │ └── render.test.js.snap │ │ ├── extensions.test.js │ │ └── filtering.test.js │ ├── __snapshots__ │ │ └── FilteringContainer.test.js.snap │ └── FilteringContainer.test.js ├── main.css ├── UnstableFastTree.js ├── eventWrappers.js ├── TreeContainer.js ├── state │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── TreeState.test.js.snap │ │ ├── TreeState.test.js │ │ └── TreeStateModifiers.test.js │ ├── TreeStateModifiers.js │ └── TreeState.js ├── FilteringContainer.js └── Tree.js ├── demo └── src │ ├── examples │ ├── Basic │ │ ├── ItemTypes.js │ │ ├── RendererDragContainer.js │ │ ├── DraggableRenderer.js │ │ └── index.js │ ├── index.js │ ├── Renderers.js │ ├── KeyboardNavigation.js │ ├── ChangeRenderers.js │ ├── LargeCollection.js │ ├── Extensions.js │ ├── Filterable.js │ ├── WorldCup.js │ └── NodeMeasure.js │ ├── index.css │ ├── containers │ ├── ExamplesContainer.css │ ├── DocumentsContainer.js │ └── ExamplesContainer.js │ ├── NavBar.css │ ├── docs │ ├── index.js │ ├── extensions.md │ ├── Doc.js │ ├── renderers.md │ └── filtering.md │ ├── index.html │ ├── index.js │ ├── toolbelt.js │ ├── NavBar.js │ └── Home.js ├── .gitignore ├── config ├── pages.js └── test │ ├── fileTransform.js │ ├── cssTransform.js │ └── test.js ├── .travis.yml ├── nwb.config.js ├── __mocks__ └── react-virtualized.js ├── CONTRIBUTING.md ├── tests └── NodeDiff.js ├── LICENSE ├── testData └── sampleTree.js ├── CHANGELOG.md ├── README.md ├── package.json └── index.d.ts /src/setupTests.js: -------------------------------------------------------------------------------- 1 | jest.unmock('react-virtualized'); 2 | -------------------------------------------------------------------------------- /demo/src/examples/Basic/ItemTypes.js: -------------------------------------------------------------------------------- 1 | export default { 2 | RENDERER: 'renderer', 3 | }; 4 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/contants.js: -------------------------------------------------------------------------------- 1 | export const UPDATE_TYPE = { 2 | ADD: 0, 3 | DELETE: 1, 4 | UPDATE: 2, 5 | }; 6 | -------------------------------------------------------------------------------- /demo/src/containers/ExamplesContainer.css: -------------------------------------------------------------------------------- 1 | .jump-to-source { 2 | float: right; 3 | color: #87CEFA; 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | /gh-pages 9 | package-lock.json 10 | yarn-error.log -------------------------------------------------------------------------------- /config/pages.js: -------------------------------------------------------------------------------- 1 | const ghpages = require('gh-pages'); 2 | const path = require('path'); 3 | 4 | ghpages.publish(path.join(__dirname, '../demo/dist'), err => { 5 | if (err) throw err; 6 | }); 7 | -------------------------------------------------------------------------------- /src/renderers/index.js: -------------------------------------------------------------------------------- 1 | import Deletable from './Deletable'; 2 | import Expandable from './Expandable'; 3 | import Favorite from './Favorite'; 4 | 5 | export default {Deletable, Expandable, Favorite}; 6 | -------------------------------------------------------------------------------- /demo/src/NavBar.css: -------------------------------------------------------------------------------- 1 | .header { 2 | margin-bottom: 20px; 3 | } 4 | 5 | .header-text { 6 | text-align: center; 7 | font-size: 70px; 8 | margin-top: 50px; 9 | } 10 | 11 | .content { 12 | padding: 20px; 13 | } -------------------------------------------------------------------------------- /demo/src/docs/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | renderers: { 3 | name: 'Renderers', 4 | }, 5 | extensions: { 6 | name: 'Extensions', 7 | }, 8 | filtering: { 9 | name: 'Filtering', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/filtering/__tests__/__snapshots__/DefaultGroupRenderer.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`DefaultGroupRenderer should render an option for each group 1`] = ` 4 | Array [ 5 | "All", 6 | "Top", 7 | "Bottom", 8 | ] 9 | `; 10 | -------------------------------------------------------------------------------- /src/shapes/rendererShapes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import {FlattenedNode} from './nodeShapes'; 4 | 5 | export const Renderer = { 6 | measure: PropTypes.func, 7 | onChange: PropTypes.func.isRequired, 8 | node: PropTypes.shape(FlattenedNode), 9 | index: PropTypes.number.isRequired, 10 | }; 11 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Tree from './TreeContainer'; 2 | import * as selectors from './selectors/nodes'; 3 | import renderers from './renderers'; 4 | import * as constants from './contants'; 5 | import FilteringContainer from './FilteringContainer'; 6 | 7 | export default Tree; 8 | export {selectors, renderers, constants, FilteringContainer}; 9 | -------------------------------------------------------------------------------- /config/test/fileTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | // This is a custom Jest transformer turning file imports into filenames. 6 | // http://facebook.github.io/jest/docs/tutorial-webpack.html 7 | 8 | module.exports = { 9 | process(src, filename) { 10 | return `module.exports = ${JSON.stringify(path.basename(filename))};`; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /config/test/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/tutorial-webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - 8 6 | 7 | before_install: 8 | - yarn add coveralls -D 9 | 10 | script: 11 | - yarn run build 12 | - yarn run test:coverage 13 | 14 | after_success: 15 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage 16 | - npx semantic-release 17 | 18 | branches: 19 | only: 20 | - master 21 | 22 | env: 23 | global: 24 | - CI: true 25 | -------------------------------------------------------------------------------- /config/test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.BABEL_ENV = 'test'; 4 | process.env.NODE_ENV = 'test'; 5 | process.env.PUBLIC_URL = ''; 6 | 7 | process.on('unhandledRejection', err => { 8 | throw err; 9 | }); 10 | 11 | const jest = require('jest'); 12 | const argv = process.argv.slice(2); 13 | 14 | // Watch unless on CI or in coverage mode 15 | if (!process.env.CI && argv.indexOf('--coverage') < 0) { 16 | argv.push('--watch'); 17 | } 18 | 19 | jest.run(argv); 20 | -------------------------------------------------------------------------------- /src/selectors/__tests__/getFlattenedTree.test.js: -------------------------------------------------------------------------------- 1 | import {getFlattenedTree, getFlattenedTreePaths} from '../getFlattenedTree'; 2 | import {Nodes} from '../../../testData/sampleTree'; 3 | 4 | describe('getFlattenedTree', () => { 5 | it('should match snapshot', () => { 6 | expect(getFlattenedTree(Nodes)).toMatchSnapshot(); 7 | }); 8 | }); 9 | 10 | test('getFlattenedTreePaths, should match snapshot', () => { 11 | expect(getFlattenedTreePaths(Nodes)).toMatchSnapshot(); 12 | }); 13 | -------------------------------------------------------------------------------- /src/filtering/DefaultGroupRenderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const DefaultGroupRenderer = ({onChange, groups, selectedGroup}) => { 4 | return ( 5 | 18 | ); 19 | }; 20 | 21 | export default DefaultGroupRenderer; 22 | -------------------------------------------------------------------------------- /src/selectors/__tests__/filtering.test.js: -------------------------------------------------------------------------------- 1 | import {filterNodes} from '../filtering'; 2 | import {Nodes} from '../../../testData/sampleTree'; 3 | 4 | describe('filtering selectors', () => { 5 | const pairNodes = n => n.id % 2 === 0; 6 | 7 | it('should filter nodes based on injected filter', () => { 8 | expect(filterNodes(pairNodes, Nodes).nodes).toMatchSnapshot(); 9 | }); 10 | 11 | it('should create mappings matching filters', () => { 12 | const pairNodes = n => n.id % 2 === 0; 13 | 14 | expect(filterNodes(pairNodes, Nodes).nodeParentMappings).toMatchSnapshot(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | const StatsPlugin = require('stats-webpack-plugin'); 2 | 3 | module.exports = { 4 | type: 'react-component', 5 | npm: { 6 | esModules: true, 7 | umd: { 8 | global: 'reactVirtualizedTree', 9 | externals: { 10 | react: 'React', 11 | 'react-dom': 'ReactDOM', 12 | 'react-virtualized': 'ReactVirtualized', 13 | }, 14 | }, 15 | }, 16 | webpack: { 17 | extra: { 18 | plugins: [new StatsPlugin('/stats.json')], 19 | }, 20 | uglify: false, 21 | html: { 22 | template: 'demo/src/index.html', 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /demo/src/examples/index.js: -------------------------------------------------------------------------------- 1 | import LargeCollection from './LargeCollection'; 2 | import Basic from './Basic'; 3 | import Renderers from './Renderers'; 4 | import WorldCup from './WorldCup'; 5 | import ChangeRenderers from './ChangeRenderers'; 6 | import Extensions from './Extensions'; 7 | import Filterable from './Filterable'; 8 | import NodeMeasure from './NodeMeasure'; 9 | import KeyboardNavigation from './KeyboardNavigation'; 10 | 11 | export default { 12 | ...Basic, 13 | ...Renderers, 14 | ...ChangeRenderers, 15 | ...WorldCup, 16 | ...LargeCollection, 17 | ...Extensions, 18 | ...Filterable, 19 | ...NodeMeasure, 20 | ...KeyboardNavigation, 21 | }; 22 | -------------------------------------------------------------------------------- /src/shapes/nodeShapes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | export const NodeState = { 4 | expanded: PropTypes.bool, 5 | deletable: PropTypes.bool, 6 | favorite: PropTypes.bool, 7 | }; 8 | 9 | const BasicNode = { 10 | id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, 11 | name: PropTypes.string, 12 | state: PropTypes.shape(NodeState), 13 | }; 14 | 15 | export const Node = { 16 | ...BasicNode, 17 | }; 18 | 19 | Node.children = PropTypes.arrayOf(PropTypes.shape(Node)); 20 | 21 | export const FlattenedNode = { 22 | ...BasicNode, 23 | deepness: PropTypes.number.isRequired, 24 | parents: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])), 25 | }; 26 | -------------------------------------------------------------------------------- /demo/src/docs/extensions.md: -------------------------------------------------------------------------------- 1 | # Extensions 2 | The entry component supports extensions to the components original functionality. The currently available extensions are: 3 | - updateTypeHandlers 4 | 5 | ## updateTypeHandlers 6 | ### () => { [updateType: number]: (nodes: Node[], updatedNode: Node) => Nodes[] 7 | Allows you to override or create new handlers, can be of use in case you either want to extend currently supported functionality (for example if you wish to flag updated nodes with a boolean property to help you filter out data before sending to the server side) or to create your own update types and handlers to support bussiness requirements. 8 | 9 | The existing update types are exported by the module inside a named export (import { constants } from 'react-virtualized-tree') 10 | -------------------------------------------------------------------------------- /demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | react-virtualized-tree demo 14 | 15 | 16 |
17 | 18 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {HashRouter, Route} from 'react-router-dom'; 4 | 5 | import NavBar from './NavBar'; 6 | import ExamplesContainer from './containers/ExamplesContainer'; 7 | import DocumentsContainer from './containers/DocumentsContainer'; 8 | import Home from './Home'; 9 | 10 | import './index.css'; 11 | import 'react-virtualized/styles.css'; 12 | import 'material-icons/css/material-icons.css'; 13 | import '../../src/main.css'; 14 | 15 | ReactDOM.render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | , 23 | document.getElementById('demo'), 24 | ); 25 | -------------------------------------------------------------------------------- /__mocks__/react-virtualized.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export class CellMeasurerCache {} 4 | 5 | export const AutoSizer = ({children}) => {children({width: 1000, height: 1000})}; 6 | // const mockInifiteLoader = ({children}) => ( 7 | // {children({registerChild: jest.fn(), onRowsRendered: jest.fn()})} 8 | // ); 9 | 10 | export const CellMeasurer = ({children}) => {children({measure: jest.fn()})}; 11 | 12 | export const List = p => 13 | Array.from({length: p.rowCount}).map((_, i) => 14 | p.rowRenderer({ 15 | key: String(i), 16 | columnIndex: 0, 17 | index: i, 18 | rowIndex: i, 19 | isScrolling: false, 20 | isVisible: true, 21 | parent: {}, 22 | style: {}, 23 | }), 24 | ); 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) >= v4 must be installed. 4 | 5 | ## Installation 6 | 7 | - Running `npm install` in the components's root directory will install everything you need for development. 8 | 9 | ## Demo Development Server 10 | 11 | - `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading. 12 | 13 | ## Running Tests 14 | 15 | - `npm test` will run the tests once. 16 | 17 | - `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`. 18 | 19 | - `npm run test:watch` will run the tests on every change. 20 | 21 | ## Building 22 | 23 | - `npm run build` will build the component for publishing to npm and also bundle the demo app. 24 | 25 | - `npm run clean` will delete built resources. 26 | -------------------------------------------------------------------------------- /demo/src/examples/Basic/RendererDragContainer.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {DragDropContext} from 'react-dnd'; 3 | import HTML5Backend from 'react-dnd-html5-backend'; 4 | import DraggableRenderer from './DraggableRenderer'; 5 | 6 | const style = { 7 | width: 400, 8 | }; 9 | 10 | @DragDropContext(HTML5Backend) 11 | export default class RendererDragContainer extends Component { 12 | render() { 13 | return ( 14 |
15 | {this.props.selectedRenderers.map((card, i) => ( 16 | 24 | ))} 25 |
26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/selectors/filtering.js: -------------------------------------------------------------------------------- 1 | const INITIAL_FILTERED_VALUE = {nodes: [], nodeParentMappings: {}}; 2 | 3 | export const filterNodes = (filter, nodes, parents = []) => 4 | nodes.reduce((filtered, n) => { 5 | const {nodes: filteredChildren, nodeParentMappings: childrenNodeMappings} = n.children 6 | ? filterNodes(filter, n.children, [...parents, n.id]) 7 | : INITIAL_FILTERED_VALUE; 8 | 9 | return !(filter(n) || filteredChildren.length) 10 | ? filtered 11 | : { 12 | nodes: [ 13 | ...filtered.nodes, 14 | { 15 | ...n, 16 | children: filteredChildren, 17 | }, 18 | ], 19 | nodeParentMappings: { 20 | ...filtered.nodeParentMappings, 21 | ...childrenNodeMappings, 22 | [n.id]: parents, 23 | }, 24 | }; 25 | }, INITIAL_FILTERED_VALUE); 26 | -------------------------------------------------------------------------------- /tests/NodeDiff.js: -------------------------------------------------------------------------------- 1 | export default class NodeDiff { 2 | added = new Set(); 3 | deleted = new Set(); 4 | changed = new Set(); 5 | _original = new Map(); 6 | 7 | constructor(originalContainer) { 8 | originalContainer.querySelectorAll('[data-type="node"]').forEach(n => { 9 | this._original.set(n.dataset.testid, n.innerHTML); 10 | }); 11 | } 12 | 13 | run(finalContainer) { 14 | finalContainer.querySelectorAll('[data-type="node"]').forEach(n => { 15 | const nodeId = n.dataset.testid; 16 | 17 | if (this._original.has(nodeId)) { 18 | if (this._original.get(nodeId) !== n.innerHTML) { 19 | this.changed.add(nodeId); 20 | } 21 | 22 | this._original.delete(nodeId); 23 | } else { 24 | this.added.add(nodeId); 25 | } 26 | }); 27 | 28 | [...this._original.keys()].forEach(k => { 29 | this.deleted.add(k); 30 | }); 31 | 32 | return { 33 | mounted: [...this.added], 34 | unmounted: [...this.deleted], 35 | changed: [...this.changed], 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /demo/src/containers/DocumentsContainer.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {Grid, Menu, Segment, Header} from 'semantic-ui-react'; 3 | import {Link, Route} from 'react-router-dom'; 4 | 5 | import documents from '../docs'; 6 | import Doc from '../docs/Doc'; 7 | import {getDocumentsPath} from '../toolbelt'; 8 | import './ExamplesContainer.css'; 9 | 10 | export default class ExamplesContainer extends Component { 11 | render() { 12 | return ( 13 |
14 | 15 | 16 | 17 | {Object.keys(documents).map(path => ( 18 | 19 | 20 | 21 | ))} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/__tests__/eventWrappers.test.js: -------------------------------------------------------------------------------- 1 | import {wrapKeyDownEvent, KEY_CODES} from '../eventWrappers'; 2 | 3 | describe('eventWrappers', () => { 4 | describe('wrapKeyDownEvent', () => { 5 | it('should call the handler for the binded keys with the correct params', () => { 6 | const bindedKeys = { 7 | [KEY_CODES.Tab]: null, 8 | [KEY_CODES.Alt]: null, 9 | [KEY_CODES.Backspace]: null, 10 | }; 11 | const params = [5, 2, 8]; 12 | 13 | Object.keys(bindedKeys).forEach(keyCode => { 14 | const handler = jest.fn(); 15 | wrapKeyDownEvent(bindedKeys)(handler)({keyCode}, ...params); 16 | 17 | expect(handler).toHaveBeenCalledWith(...params); 18 | }); 19 | }); 20 | 21 | it('should not call the handler for keys that are not binded', () => { 22 | const bindedKeys = { 23 | [KEY_CODES.Tab]: null, 24 | }; 25 | const params = [5, 2, 8]; 26 | const handler = jest.fn(); 27 | wrapKeyDownEvent(bindedKeys)(handler)({keyCode: KEY_CODES.Backspace}, ...params); 28 | 29 | expect(handler).not.toHaveBeenCalled(); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Diogo Cunha 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 | -------------------------------------------------------------------------------- /src/renderers/Deletable.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import {submitEvent} from '../eventWrappers'; 6 | import {getNodeRenderOptions, deleteNode} from '../selectors/nodes'; 7 | import {Renderer} from '../shapes/rendererShapes'; 8 | 9 | const Deletable = ({ 10 | onChange, 11 | node, 12 | iconsClassNameMap = { 13 | delete: 'mi mi-delete', 14 | }, 15 | children, 16 | index, 17 | }) => { 18 | const {isDeletable} = getNodeRenderOptions(node); 19 | 20 | const className = classNames({ 21 | [iconsClassNameMap.delete]: isDeletable, 22 | }); 23 | 24 | const handleChange = () => onChange({...deleteNode(node), index}); 25 | 26 | return ( 27 | 28 | {isDeletable && ( 29 | 30 | )} 31 | {children} 32 | 33 | ); 34 | }; 35 | 36 | Deletable.propTypes = { 37 | ...Renderer, 38 | iconsClassNameMap: PropTypes.shape({ 39 | delete: PropTypes.string, 40 | }), 41 | }; 42 | 43 | export default Deletable; 44 | -------------------------------------------------------------------------------- /demo/src/examples/Renderers.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import Tree from '../../../src/TreeContainer'; 5 | import {Nodes} from '../../../testData/sampleTree'; 6 | import {createEntry} from '../toolbelt'; 7 | 8 | const Deepness = ({node, children}) => { 9 | const deepness = node.deepness + 1; 10 | const className = classNames({ 11 | [`mi mi-filter-${deepness}`]: deepness <= 9, 12 | 'filter-9-plus': deepness > 9, 13 | }); 14 | 15 | return ( 16 | 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | class Renderers extends Component { 24 | render() { 25 | return ( 26 | 27 | {({style, node, ...rest}) => ( 28 |
29 | 30 | {node.name} 31 | 32 |
33 | )} 34 |
35 | ); 36 | } 37 | } 38 | 39 | export default createEntry( 40 | 'renderers', 41 | 'Renderers', 42 | 'Create a custom renderer', 43 |
44 |

A tree that makes use of a custom renderer

45 |
, 46 | Renderers, 47 | ); 48 | -------------------------------------------------------------------------------- /src/renderers/Favorite.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import {submitEvent} from '../eventWrappers'; 6 | import {getNodeRenderOptions, updateNode} from '../selectors/nodes'; 7 | import {Renderer} from '../shapes/rendererShapes'; 8 | 9 | const Favorite = ({ 10 | onChange, 11 | node, 12 | iconsClassNameMap = { 13 | favorite: 'mi mi-star', 14 | notFavorite: 'mi mi-star-border', 15 | }, 16 | children, 17 | index, 18 | }) => { 19 | const {isFavorite} = getNodeRenderOptions(node); 20 | 21 | const className = classNames({ 22 | [iconsClassNameMap.favorite]: isFavorite, 23 | [iconsClassNameMap.notFavorite]: !isFavorite, 24 | }); 25 | 26 | const handleChange = () => onChange({...updateNode(node, {favorite: !isFavorite}), index}); 27 | 28 | return ( 29 | 30 | 31 | {children} 32 | 33 | ); 34 | }; 35 | 36 | Favorite.propTypes = { 37 | ...Renderer, 38 | iconsClassNameMap: PropTypes.shape({ 39 | favorite: PropTypes.string, 40 | notFavorite: PropTypes.string, 41 | }), 42 | }; 43 | 44 | export default Favorite; 45 | -------------------------------------------------------------------------------- /src/selectors/getFlattenedTree.js: -------------------------------------------------------------------------------- 1 | export const isNodeExpanded = node => node.state && node.state.expanded; 2 | export const nodeHasChildren = node => node.children && node.children.length; 3 | 4 | export const getFlattenedTree = (nodes, parents = []) => 5 | nodes.reduce((flattenedTree, node) => { 6 | const deepness = parents.length; 7 | const nodeWithHelpers = {...node, deepness, parents}; 8 | 9 | if (!nodeHasChildren(node) || !isNodeExpanded(node)) { 10 | return [...flattenedTree, nodeWithHelpers]; 11 | } 12 | 13 | return [...flattenedTree, nodeWithHelpers, ...getFlattenedTree(node.children, [...parents, node.id])]; 14 | }, []); 15 | 16 | export const getFlattenedTreePaths = (nodes, parents = []) => { 17 | const paths = []; 18 | 19 | for (const node of nodes) { 20 | const {id} = node; 21 | 22 | if (!nodeHasChildren(node) || !isNodeExpanded(node)) { 23 | paths.push(parents.concat(id)); 24 | } else { 25 | paths.push(parents.concat(id)); 26 | paths.push(...getFlattenedTreePaths(node.children, [...parents, id])); 27 | } 28 | } 29 | 30 | return paths; 31 | }; 32 | 33 | export const doesChangeAffectFlattenedTree = (previousNode, nextNode) => { 34 | return isNodeExpanded(previousNode) !== isNodeExpanded(nextNode); 35 | }; 36 | -------------------------------------------------------------------------------- /demo/src/docs/Doc.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {Loader} from 'semantic-ui-react'; 3 | import ReactMarkdown from 'react-markdown'; 4 | 5 | import documents from './'; 6 | import {getDocumentFetchUrl} from '../toolbelt'; 7 | 8 | import {polyfill} from 'react-lifecycles-compat'; 9 | 10 | class Doc extends Component { 11 | state = { 12 | doc: null, 13 | }; 14 | 15 | componentDidMount() { 16 | const { 17 | match: { 18 | params: {document}, 19 | }, 20 | } = this.props; 21 | 22 | this.getDocument(document); 23 | } 24 | 25 | getDocument = doc => { 26 | return fetch(getDocumentFetchUrl(doc)) 27 | .then(r => r.text()) 28 | .then(doc => { 29 | this.setState({doc}); 30 | }); 31 | }; 32 | 33 | UNSAFE_componentWillReceiveProps({ 34 | match: { 35 | params: {document}, 36 | }, 37 | }) { 38 | const { 39 | match: { 40 | params: {document: selectedDocument}, 41 | }, 42 | } = this.props; 43 | 44 | if (document !== selectedDocument) { 45 | this.setState({doc: null}, () => { 46 | this.getDocument(document); 47 | }); 48 | } 49 | } 50 | 51 | render() { 52 | const {doc} = this.state; 53 | 54 | return !doc ? : ; 55 | } 56 | } 57 | 58 | polyfill(Doc); 59 | 60 | export default Doc; 61 | -------------------------------------------------------------------------------- /demo/src/examples/KeyboardNavigation.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import FocusTrap from 'focus-trap-react'; 3 | import Tree from '../../../src/TreeContainer'; 4 | import {Nodes} from '../../../testData/sampleTree'; 5 | import {createEntry} from '../toolbelt'; 6 | import Renderers from '../../../src/renderers'; 7 | 8 | const {Expandable, Favorite} = Renderers; 9 | 10 | class KeyboardNavigation extends Component { 11 | state = { 12 | nodes: Nodes, 13 | trapFocus: false, 14 | }; 15 | 16 | handleChange = nodes => { 17 | this.setState({nodes}); 18 | }; 19 | 20 | nodeRenderer = ({style, node, ...rest}) => { 21 | return ( 22 |
23 | 24 | 25 | {node.name} 26 | 27 | 28 |
29 | ); 30 | }; 31 | 32 | render() { 33 | return ( 34 | 35 | 36 | {this.nodeRenderer} 37 | 38 | 39 | ); 40 | } 41 | } 42 | 43 | export default createEntry( 44 | 'keyboard-nav', 45 | 'KeyboardNavigation', 46 | 'Keyboard navigation', 47 |
48 |

A tree that supports keyboard navigation

49 |
, 50 | KeyboardNavigation, 51 | ); 52 | -------------------------------------------------------------------------------- /src/renderers/Expandable.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import {submitEvent} from '../eventWrappers'; 6 | import {getNodeRenderOptions, updateNode} from '../selectors/nodes'; 7 | import {Renderer} from '../shapes/rendererShapes'; 8 | 9 | const Expandable = ({ 10 | onChange, 11 | node, 12 | children, 13 | index, 14 | iconsClassNameMap = { 15 | expanded: 'mi mi-keyboard-arrow-down', 16 | collapsed: 'mi mi-keyboard-arrow-right', 17 | lastChild: '', 18 | }, 19 | }) => { 20 | const {hasChildren, isExpanded} = getNodeRenderOptions(node); 21 | const className = classNames({ 22 | [iconsClassNameMap.expanded]: hasChildren && isExpanded, 23 | [iconsClassNameMap.collapsed]: hasChildren && !isExpanded, 24 | [iconsClassNameMap.lastChild]: !hasChildren, 25 | }); 26 | 27 | const handleChange = () => onChange({...updateNode(node, {expanded: !isExpanded}), index}); 28 | 29 | return ( 30 | 31 | {hasChildren && ( 32 | 33 | )} 34 | {children} 35 | 36 | ); 37 | }; 38 | 39 | Expandable.propTypes = { 40 | ...Renderer, 41 | iconsClassNameMap: PropTypes.shape({ 42 | expanded: PropTypes.string, 43 | collapsed: PropTypes.string, 44 | lastChild: PropTypes.string, 45 | }), 46 | }; 47 | 48 | export default Expandable; 49 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | .tree-lookup-input { 2 | font-size: 1em; 3 | position: relative; 4 | font-weight: 400; 5 | font-style: normal; 6 | color: rgba(0, 0, 0, 0.87); 7 | } 8 | 9 | .tree-lookup-input input { 10 | width: 100%; 11 | margin: 0; 12 | outline: 0; 13 | -webkit-tap-highlight-color: rgba(255, 255, 255, 0); 14 | text-align: left; 15 | line-height: 1.21428571em; 16 | padding: 0.7em 1em; 17 | background: #fff; 18 | border: 1px solid rgba(34, 36, 38, 0.15); 19 | color: rgba(0, 0, 0, 0.87); 20 | border-radius: 0.28571429rem; 21 | -webkit-transition: box-shadow 0.1s ease, border.1s ease; 22 | transition: box-shadow 0.1s ease, border 0.1s ease; 23 | box-shadow: none; 24 | padding-right: 2.67142857em !important; 25 | margin-bottom: 7px; 26 | } 27 | 28 | .tree-lookup-input.group input { 29 | width: 80%; 30 | } 31 | 32 | .tree-lookup-input input:focus { 33 | border: 1px solid #85b7d9; 34 | background: #fff; 35 | color: rgba(0, 0, 0, 0.8); 36 | box-shadow: none; 37 | } 38 | 39 | .tree-lookup-input i { 40 | margin-left: -3em; 41 | padding: 0.7em 1em; 42 | color: rgba(0, 0, 0, 0.3); 43 | cursor: text; 44 | } 45 | 46 | .tree-filter-container { 47 | border: 1px solid rgba(0, 0, 0, 0.3); 48 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); 49 | padding: 2%; 50 | } 51 | 52 | .tree-group { 53 | width: 20%; 54 | appearance: none; 55 | background-color: #2185d0; 56 | color: #fff; 57 | cursor: pointer; 58 | border: 1px solid #2185d0; 59 | font-weight: 700; 60 | padding: 0.7em 1em; 61 | } 62 | -------------------------------------------------------------------------------- /demo/src/docs/renderers.md: -------------------------------------------------------------------------------- 1 | # Renderers 2 | 3 | A renderer is a component you can use to build your tree stucture. The power of it is that you can either use the renderers shipped with this package or you can create your own! A few examples on how to achieve it can be found at the examples section. 4 | 5 | ## Injected props 6 | 7 | | Name | Type | Description | 8 | | -------- | ---------------------------------------------- | ------------------------------------------------------------------------------------- | 9 | | onChange | ({ node: Node _, type: UPDATE_TYPE _}) => void | to be invoked with the updated node and the update type | 10 | | node | Node \* | The node being rendered for that row, with some additional info | 11 | | children | JSX | Anything your render as children of the component when using it | 12 | | style | React.CSSProperties | A opt in style property that will auto align your items and apply some default styles | 13 | | measure | () => void | A function that can be used to let the tree know about your node measurements | 14 | 15 | - Node = { 16 | id: number 17 | name: string, 18 | state: object, 19 | deepness: number, 20 | parents: number[] 21 | } 22 | - UPDATE_TYPE = { 23 | ADD: 0, 24 | DELETE: 1, 25 | UPDATE: 2 26 | } 27 | -------------------------------------------------------------------------------- /src/selectors/__tests__/__snapshots__/filtering.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`filtering selectors should create mappings matching filters 1`] = ` 4 | Object { 5 | "0": Array [], 6 | "1": Array [], 7 | "2": Array [ 8 | 0, 9 | ], 10 | "4": Array [ 11 | 0, 12 | 2, 13 | ], 14 | "6": Array [ 15 | 1, 16 | ], 17 | "8": Array [ 18 | 1, 19 | 6, 20 | ], 21 | } 22 | `; 23 | 24 | exports[`filtering selectors should filter nodes based on injected filter 1`] = ` 25 | Array [ 26 | Object { 27 | "children": Array [ 28 | Object { 29 | "children": Array [ 30 | Object { 31 | "children": Array [], 32 | "id": 4, 33 | "name": "Leaf 4", 34 | }, 35 | ], 36 | "id": 2, 37 | "name": "Leaf 2", 38 | "state": Object { 39 | "deletable": true, 40 | "expanded": true, 41 | }, 42 | }, 43 | ], 44 | "id": 0, 45 | "name": "Leaf 1", 46 | "state": Object { 47 | "expanded": true, 48 | }, 49 | }, 50 | Object { 51 | "children": Array [ 52 | Object { 53 | "children": Array [ 54 | Object { 55 | "children": Array [], 56 | "id": 8, 57 | "name": "Leaf 9", 58 | }, 59 | ], 60 | "id": 6, 61 | "name": "Leaf 7", 62 | "state": Object { 63 | "expanded": false, 64 | }, 65 | }, 66 | ], 67 | "id": 1, 68 | "name": "Leaf 6", 69 | "state": Object { 70 | "deletable": true, 71 | "expanded": false, 72 | }, 73 | }, 74 | ] 75 | `; 76 | -------------------------------------------------------------------------------- /demo/src/toolbelt.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | const pkg = require('../../package'); 4 | 5 | export const getRepoPath = () => pkg.repository; 6 | 7 | export const getExamplePath = name => `${getRepoPath()}/blob/master/demo/src/examples/${name}.js`; 8 | 9 | export const getDocumentFetchUrl = doc => { 10 | const docPath = path.join(getRepoPath(), 'master', `demo/src/docs/${doc}.md`); 11 | 12 | const url = new URL(docPath); 13 | url.hostname = 'raw.githubusercontent.com'; 14 | 15 | return url.href; 16 | }; 17 | 18 | export const createEntry = (key, fileName, name, description, component) => ({ 19 | [key]: { 20 | name, 21 | fileName, 22 | description, 23 | component, 24 | }, 25 | }); 26 | 27 | let ids = {}; 28 | 29 | const getUniqueId = () => { 30 | const candidateId = Math.round(Math.random() * 1000000000); 31 | 32 | if (ids[candidateId]) { 33 | return getUniqueId(); 34 | } 35 | 36 | ids[candidateId] = true; 37 | 38 | return candidateId; 39 | }; 40 | 41 | export const constructTree = (maxDeepness, maxNumberOfChildren, minNumOfNodes, deepness = 1) => { 42 | return new Array(minNumOfNodes).fill(deepness).map((si, i) => { 43 | const id = getUniqueId(); 44 | const numberOfChildren = deepness === maxDeepness ? 0 : Math.round(Math.random() * maxNumberOfChildren); 45 | 46 | return { 47 | id, 48 | name: `Leaf ${id}`, 49 | children: numberOfChildren ? constructTree(maxDeepness, maxNumberOfChildren, numberOfChildren, deepness + 1) : [], 50 | state: { 51 | expanded: numberOfChildren ? Boolean(Math.round(Math.random())) : false, 52 | favorite: Boolean(Math.round(Math.random())), 53 | deletable: Boolean(Math.round(Math.random())), 54 | }, 55 | }; 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /testData/sampleTree.js: -------------------------------------------------------------------------------- 1 | export const COLLAPSED_CHILDREN = { 2 | id: 3, 3 | name: 'Leaf 3', 4 | state: { 5 | expanded: false, 6 | favorite: true, 7 | deletable: true, 8 | }, 9 | children: [ 10 | { 11 | id: 'c-3', 12 | name: 'Leaf 3 Child', 13 | state: {}, 14 | }, 15 | ], 16 | }; 17 | 18 | export const EXPANDED_CHILDREN = { 19 | id: 2, 20 | name: 'Leaf 2', 21 | state: { 22 | expanded: true, 23 | deletable: true, 24 | }, 25 | children: [ 26 | COLLAPSED_CHILDREN, 27 | { 28 | id: 4, 29 | name: 'Leaf 4', 30 | }, 31 | ], 32 | }; 33 | 34 | export const EXPANDED_NODE_IN_ROOT = { 35 | id: 0, 36 | name: 'Leaf 1', 37 | state: { 38 | expanded: true, 39 | }, 40 | children: [ 41 | EXPANDED_CHILDREN, 42 | { 43 | id: 5, 44 | name: 'Leaf 5', 45 | }, 46 | ], 47 | }; 48 | 49 | export const COLLAPSED_NODE_IN_ROOT = { 50 | id: 1, 51 | name: 'Leaf 6', 52 | state: { 53 | expanded: false, 54 | deletable: true, 55 | }, 56 | children: [ 57 | { 58 | id: 6, 59 | name: 'Leaf 7', 60 | state: { 61 | expanded: false, 62 | }, 63 | children: [ 64 | { 65 | id: 7, 66 | name: 'Leaf 8', 67 | }, 68 | { 69 | id: 8, 70 | name: 'Leaf 9', 71 | }, 72 | ], 73 | }, 74 | { 75 | id: 9, 76 | name: 'Leaf 10', 77 | }, 78 | ], 79 | }; 80 | 81 | export const DELETABLE_IN_ROOT = { 82 | id: 'z', 83 | name: 'Leaf z', 84 | state: { 85 | deletable: true, 86 | favorite: true, 87 | }, 88 | }; 89 | 90 | export const DELETABLE_CHILDREN = EXPANDED_CHILDREN; 91 | 92 | export const Nodes = [EXPANDED_NODE_IN_ROOT, COLLAPSED_NODE_IN_ROOT, DELETABLE_IN_ROOT]; 93 | -------------------------------------------------------------------------------- /demo/src/examples/ChangeRenderers.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | import Tree from '../../../src/TreeContainer'; 4 | import Renderers from '../../../src/renderers'; 5 | import {Nodes} from '../../../testData/sampleTree'; 6 | import {createEntry} from '../toolbelt'; 7 | 8 | const {Expandable} = Renderers; 9 | 10 | class ChangeRenderers extends Component { 11 | state = { 12 | nodes: Nodes, 13 | }; 14 | 15 | handleChange = nodes => { 16 | this.setState({nodes}); 17 | }; 18 | 19 | render() { 20 | return ( 21 | 22 | {({style, node, ...rest}) => ( 23 |
24 | 33 | {node.name} 34 | 35 |
36 | )} 37 |
38 | ); 39 | } 40 | } 41 | 42 | export default createEntry( 43 | 'customize-renderers', 44 | 'ChangeRenderers', 45 | 'Customize default renderers', 46 |
47 |

48 | A good example of a possible customization of a default renderer is customizing the tree to display as a folder 49 | structure. 50 |

51 | 52 |

53 | By exposing iconsClassNameMap it is possible to pass in the styles applied to the Expandable 54 | rendererer, the available style options are: 55 |

56 | {'{ '} 57 | expanded: string; collapsed: string; lastChild: string; 58 | {' }'} 59 |
, 60 | ChangeRenderers, 61 | ); 62 | -------------------------------------------------------------------------------- /src/UnstableFastTree.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Tree from './Tree'; 5 | import {Node} from './shapes/nodeShapes'; 6 | import TreeStateModifiers from './state/TreeStateModifiers'; 7 | import {UPDATE_TYPE} from './contants'; 8 | 9 | export default class UnstableFastTree extends React.Component { 10 | static contextTypes = { 11 | unfilteredNodes: PropTypes.arrayOf(PropTypes.shape(Node)), 12 | }; 13 | 14 | get nodes() { 15 | return this.context.unfilteredNodes || this.props.nodes; 16 | } 17 | 18 | handleChange = ({node, type, index}) => { 19 | let nodes; 20 | 21 | if (type === UPDATE_TYPE.UPDATE) { 22 | nodes = TreeStateModifiers.editNodeAt(this.props.nodes, index, node); 23 | } else { 24 | nodes = TreeStateModifiers.deleteNodeAt(this.props.nodes, index); 25 | } 26 | 27 | this.props.onChange(nodes); 28 | }; 29 | 30 | render() { 31 | return ( 32 | 38 | ); 39 | } 40 | } 41 | 42 | UnstableFastTree.propTypes = { 43 | extensions: PropTypes.shape({ 44 | updateTypeHandlers: PropTypes.object, 45 | }), 46 | nodes: PropTypes.shape({ 47 | flattenedTree: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.oneOf([PropTypes.number, PropTypes.string]))) 48 | .isRequired, 49 | tree: PropTypes.arrayOf(PropTypes.shape(Node)).isRequired, 50 | }), 51 | onChange: PropTypes.func, 52 | children: PropTypes.func.isRequired, 53 | nodeMarginLeft: PropTypes.number, 54 | width: PropTypes.number, 55 | scrollToId: PropTypes.number, 56 | }; 57 | 58 | UnstableFastTree.defaultProps = { 59 | nodeMarginLeft: 30, 60 | }; 61 | -------------------------------------------------------------------------------- /src/filtering/__tests__/DefaultGroupRenderer.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, cleanup, fireEvent} from 'react-testing-library'; 3 | 4 | import DefaultGroupRenderer from '../DefaultGroupRenderer'; 5 | 6 | describe('DefaultGroupRenderer', () => { 7 | const setup = (extraProps = {}) => { 8 | const props = { 9 | onChange: jest.fn(), 10 | groups: { 11 | ALL: { 12 | name: 'All', 13 | filter: () => {}, 14 | }, 15 | TOP: { 16 | name: 'Top', 17 | filter: () => {}, 18 | }, 19 | BOTTOM: { 20 | name: 'Bottom', 21 | filter: () => {}, 22 | }, 23 | }, 24 | selectedGroup: 'TOP', 25 | ...extraProps, 26 | }; 27 | 28 | return { 29 | ...render(), 30 | props, 31 | }; 32 | }; 33 | 34 | it('should render an option for each group', () => { 35 | const {container} = setup(); 36 | const options = container.querySelectorAll('option'); 37 | 38 | const optionsText = []; 39 | 40 | options.forEach(o => { 41 | optionsText.push(o.text); 42 | }); 43 | 44 | expect(optionsText).toMatchSnapshot(); 45 | }); 46 | 47 | it('should render the select with the correct value', () => { 48 | const {container, props} = setup(); 49 | const select = container.querySelector('select'); 50 | 51 | expect(select.value).toBe(props.selectedGroup); 52 | }); 53 | 54 | it('changing the selection should call onChange with the correct params', () => { 55 | const {props, container} = setup(); 56 | const value = 'BOTTOM'; 57 | 58 | const select = container.querySelector('select'); 59 | select.value = value; 60 | 61 | fireEvent.change(select); 62 | 63 | expect(props.onChange).toHaveBeenCalledWith(value); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /demo/src/examples/LargeCollection.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | import UnstableFastTree from '../../../src/UnstableFastTree'; 4 | import Renderers from '../../../src/renderers'; 5 | import {createEntry, constructTree} from '../toolbelt'; 6 | import TreeState from '../../../src/state/TreeState'; 7 | 8 | const MIN_NUMBER_OF_PARENTS = 500; 9 | const MAX_NUMBER_OF_CHILDREN = 30; 10 | const MAX_DEEPNESS = 4; 11 | 12 | const {Deletable, Expandable, Favorite} = Renderers; 13 | 14 | const Nodes = constructTree(MAX_DEEPNESS, MAX_NUMBER_OF_CHILDREN, MIN_NUMBER_OF_PARENTS); 15 | const getTotalNumberOfElements = (nodes, counter = 0) => { 16 | return counter + nodes.length + nodes.reduce((acc, n) => getTotalNumberOfElements(n.children, acc), 0); 17 | }; 18 | 19 | const totalNumberOfNodes = getTotalNumberOfElements(Nodes); 20 | 21 | class LargeCollection extends Component { 22 | state = { 23 | nodes: TreeState.createFromTree(Nodes), 24 | }; 25 | 26 | handleChange = nodes => { 27 | this.setState({nodes}); 28 | }; 29 | 30 | render() { 31 | return ( 32 | 33 | {({style, node, ...rest}) => ( 34 |
35 | 36 | {node.name} 37 | 38 | 39 | 40 | 41 |
42 | )} 43 |
44 | ); 45 | } 46 | } 47 | 48 | export default createEntry( 49 | 'large-collection', 50 | 'LargeCollection', 51 | 'Large Data Collection', 52 |
53 |

A tree that renders a large collection of nodes.

54 |

This example is rendering a total of {totalNumberOfNodes} nodes

55 |
, 56 | LargeCollection, 57 | ); 58 | -------------------------------------------------------------------------------- /demo/src/containers/ExamplesContainer.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {Grid, Menu, Segment, Header} from 'semantic-ui-react'; 3 | import {Route} from 'react-router'; 4 | import examples from '../examples'; 5 | import {Link} from 'react-router-dom'; 6 | import {getExamplePath} from '../toolbelt'; 7 | import './ExamplesContainer.css'; 8 | 9 | export default class ExamplesContainer extends Component { 10 | render() { 11 | return ( 12 |
13 | 14 | 15 | 16 | {Object.keys(examples).map(path => ( 17 | 18 | 19 | 20 | ))} 21 | 22 | 23 | 24 | 25 | { 28 | const selectedExample = examples[p.match.params.example]; 29 | const {component: Component, name, description, fileName} = selectedExample; 30 | 31 | return ( 32 |
33 | 34 | Jump to source 35 | 36 |
{name}
37 | {description && {description}} 38 |
39 | 40 |
41 |
42 | ); 43 | }} 44 | /> 45 |
46 |
47 |
48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /demo/src/docs/filtering.md: -------------------------------------------------------------------------------- 1 | # FilteringContainer 2 | FilteringComponent is a component that can be used to wrap the Tree in order to get filtering and group functionality for free. 3 | 4 | To use this component you should know a little bit about [using functions as children](https://codedaily.io/tutorials/6/Using-Functions-as-Children-and-Render-Props-in-React-Components). 5 | 6 | ## Props 7 | 8 | | Name | Type | Description | 9 | | --------------------- | -------------| ----------- | 10 | | nodes | Node[] | List of original nodes | 11 | | children | ({ nodes: Node[] }) => JSX | A renderer for the tree| 12 | | debouncer? | (setFilter: Func, timeout) => void| Debounce search results, default to lodash debouncer with 300ms timeout| 13 | | groups? | { [groupKey: string]: *Group } | Groups with selectors to quickly filter results | 14 | | selectedGroup? | string | Selected group key | 15 | | groupRenderer? | Component<*RendererProps> | Custom group renderer | 16 | | onSelectedGroupChange?| (groupKey: string) => void | A handler invoked when group selection changes | 17 | 18 | 19 | ## Props Injected in children function 20 | | Name | Type | Description | 21 | | ------------- | -------------| ----------- | 22 | | nodes | Node[] | List of nodes after filters being applied | 23 | 24 | ## Created context 25 | | Name | Type | Description | 26 | | ---------------| --------| ----------- | 27 | | unfilteredNodes| Node[] | List of nodes originally injected in the component | 28 | 29 | * Node = { 30 | id: number 31 | name: string, 32 | state: object 33 | } 34 | 35 | * Group = { 36 | name: string, 37 | filter: (node: Node) => boolean 38 | } 39 | 40 | * RendererProps = { 41 | onChange: (groupKey: string) => void, 42 | groups: { [groupKey: string]: *Group }, 43 | selectedGroup: string 44 | } 45 | -------------------------------------------------------------------------------- /demo/src/NavBar.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {Sidebar, Segment, Menu, Image, Grid} from 'semantic-ui-react'; 3 | import {Link} from 'react-router-dom'; 4 | 5 | import {getRepoPath} from './toolbelt'; 6 | import './NavBar.css'; 7 | 8 | class NavBar extends Component { 9 | render() { 10 | return ( 11 |
12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | react-virtualized-tree 21 | 22 | 23 |
24 | {this.props.children} 25 |
26 | 27 | 37 | 38 | Setup 39 | 40 | 41 | Documentation 42 | 43 | 44 | Examples 45 | 46 | 47 | GitHub 48 | 49 | 50 | 51 |
52 |
53 | ); 54 | } 55 | } 56 | 57 | export default NavBar; 58 | -------------------------------------------------------------------------------- /demo/src/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Segment, Header} from 'semantic-ui-react'; 3 | 4 | export default () => ( 5 |
6 |
Introduction
7 | 8 |

9 | react-virtualized-tree is a react library built on top of{' '} 10 | react-virtualized. 11 |

12 |

Its main goal is to display tree like data in a beautiful and fast way.

13 |

Being a reactive library it uses children functions to achieve maximum extensibility

14 |

15 | The core idea behind it is that anyone using it is enable to create a tree as they wich just by rendering their 16 | own components or components exported by the tree 17 |

18 |
19 |
Installation
20 | 21 |

You can install via npm or yarn.

22 | 23 | npm i react-virtualized-tree --save 24 | 25 | 26 | yarn add react-virtualized-tree 27 | 28 | To get the basic styles for free you need to import react-virtualized styles only once. 29 | 30 | import 'react-virtualized/styles.css'; 31 | 32 | 33 | import 'react-virtualized-tree/lib/main.css'; 34 | 35 |

If you want to use the icons in the default renderers do the same for material icons.

36 | 37 | import 'material-icons/css/material-icons.css'; 38 | 39 |
40 |
Dependencies
41 | 42 |

43 | Most react-virtualized-tree Dependencies are managed internally, the only required peerDependencies are{' '} 44 | react, react-dom and react-virtualized. 45 |

46 |
47 |
48 | ); 49 | -------------------------------------------------------------------------------- /src/eventWrappers.js: -------------------------------------------------------------------------------- 1 | export const wrapKeyDownEvent = availablekeys => handler => ({keyCode}, ...params) => { 2 | if (keyCode in availablekeys) { 3 | handler(...params); 4 | } 5 | }; 6 | 7 | export const KEY_CODES = { 8 | Backspace: 8, 9 | Tab: 9, 10 | Enter: 13, 11 | Shift: 16, 12 | Ctrl: 17, 13 | Alt: 18, 14 | PauseBreak: 19, 15 | CapsLock: 20, 16 | Escape: 27, 17 | PageUp: 33, 18 | PageDown: 34, 19 | End: 35, 20 | Home: 36, 21 | LeftArrow: 37, 22 | UpArrow: 38, 23 | RightArrow: 39, 24 | DownArrow: 40, 25 | Insert: 45, 26 | Delete: 46, 27 | 0: 48, 28 | 1: 49, 29 | 2: 50, 30 | 3: 51, 31 | 4: 52, 32 | 5: 53, 33 | 6: 54, 34 | 7: 55, 35 | 8: 56, 36 | 9: 57, 37 | a: 65, 38 | b: 66, 39 | c: 67, 40 | d: 68, 41 | e: 69, 42 | f: 70, 43 | g: 71, 44 | h: 72, 45 | i: 73, 46 | j: 74, 47 | k: 75, 48 | l: 76, 49 | m: 77, 50 | n: 78, 51 | o: 79, 52 | p: 80, 53 | q: 81, 54 | r: 82, 55 | s: 83, 56 | t: 84, 57 | u: 85, 58 | v: 86, 59 | w: 87, 60 | x: 88, 61 | y: 89, 62 | z: 90, 63 | LeftWindowKey: 91, 64 | RightWindowKey: 92, 65 | SelectKey: 93, 66 | NumPad0: 96, 67 | NumPad1: 97, 68 | NumPad2: 98, 69 | NumPad3: 99, 70 | NumPad4: 100, 71 | NumPad5: 101, 72 | NumPad6: 102, 73 | NumPad7: 103, 74 | NumPad8: 104, 75 | NumPad9: 105, 76 | Multiply: 106, 77 | Add: 107, 78 | Subtract: 109, 79 | DecimalPoint: 110, 80 | Divide: 111, 81 | F1: 112, 82 | F2: 113, 83 | F3: 114, 84 | F4: 115, 85 | F5: 116, 86 | F6: 117, 87 | F7: 118, 88 | F8: 119, 89 | F9: 120, 90 | F10: 121, 91 | F12: 123, 92 | NumLock: 144, 93 | ScrollLock: 145, 94 | SemiColon: 186, 95 | EqualSign: 187, 96 | Comma: 188, 97 | Dash: 189, 98 | Period: 190, 99 | ForwardSlash: 191, 100 | GraveAccent: 192, 101 | OpenBracket: 219, 102 | BackSlash: 220, 103 | CloseBracket: 221, 104 | SingleQuote: 222, 105 | }; 106 | 107 | export const submitEvent = wrapKeyDownEvent({[KEY_CODES.Enter]: null}); 108 | -------------------------------------------------------------------------------- /src/TreeContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Tree from './Tree'; 5 | import {UPDATE_TYPE} from './contants'; 6 | import {getFlattenedTree} from './selectors/getFlattenedTree'; 7 | import {deleteNodeFromTree, replaceNodeFromTree, getRowIndexFromId} from './selectors/nodes'; 8 | import {Node} from './shapes/nodeShapes'; 9 | import {createSelector} from 'reselect'; 10 | 11 | const DEFAULT_UPDATE_TYPES = { 12 | [UPDATE_TYPE.DELETE]: deleteNodeFromTree, 13 | [UPDATE_TYPE.UPDATE]: replaceNodeFromTree, 14 | }; 15 | 16 | const getExtensions = createSelector( 17 | e => e, 18 | (extensions = {}) => { 19 | const {updateTypeHandlers = {}} = extensions; 20 | 21 | return { 22 | updateTypeHandlers: { 23 | ...DEFAULT_UPDATE_TYPES, 24 | ...updateTypeHandlers, 25 | }, 26 | }; 27 | }, 28 | ); 29 | 30 | export default class TreeContainer extends React.Component { 31 | static contextTypes = { 32 | unfilteredNodes: PropTypes.arrayOf(PropTypes.shape(Node)), 33 | }; 34 | 35 | get nodes() { 36 | return this.context.unfilteredNodes || this.props.nodes; 37 | } 38 | 39 | handleChange = ({node, type}) => { 40 | const updatedNodes = getExtensions(this.props.extensions).updateTypeHandlers[type](this.nodes, node); 41 | 42 | this.props.onChange(updatedNodes); 43 | }; 44 | 45 | render() { 46 | const flattenedTree = getFlattenedTree(this.props.nodes); 47 | const rowIndex = getRowIndexFromId(flattenedTree, this.props.scrollToId); 48 | return ( 49 | 58 | ); 59 | } 60 | } 61 | 62 | TreeContainer.propTypes = { 63 | extensions: PropTypes.shape({ 64 | updateTypeHandlers: PropTypes.object, 65 | }), 66 | nodes: PropTypes.arrayOf(PropTypes.shape(Node)).isRequired, 67 | onChange: PropTypes.func, 68 | children: PropTypes.func.isRequired, 69 | nodeMarginLeft: PropTypes.number, 70 | width: PropTypes.number, 71 | scrollToId: PropTypes.number, 72 | scrollToAlignment: PropTypes.string, 73 | }; 74 | 75 | TreeContainer.defaultProps = { 76 | nodeMarginLeft: 30, 77 | }; 78 | -------------------------------------------------------------------------------- /src/state/__tests__/__snapshots__/TreeState.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TreeState createFromTree should create state when a valid tree is supplied 1`] = ` 4 | Array [ 5 | Array [ 6 | 0, 7 | ], 8 | Array [ 9 | 0, 10 | 2, 11 | ], 12 | Array [ 13 | 0, 14 | 2, 15 | 3, 16 | ], 17 | Array [ 18 | 0, 19 | 2, 20 | 4, 21 | ], 22 | Array [ 23 | 0, 24 | 5, 25 | ], 26 | Array [ 27 | 1, 28 | ], 29 | Array [ 30 | "z", 31 | ], 32 | ] 33 | `; 34 | 35 | exports[`TreeState getNodeAt should fail with a custom error when supplied rowId does not exist 1`] = `"Tried to get node at row \\"25\\" but got nothing, the tree are 7 visible rows"`; 36 | 37 | exports[`TreeState getNodeAt should get a for an existing rowId: 2nd row 1`] = ` 38 | Object { 39 | "children": Array [ 40 | Object { 41 | "children": Array [ 42 | Object { 43 | "id": "c-3", 44 | "name": "Leaf 3 Child", 45 | "state": Object {}, 46 | }, 47 | ], 48 | "id": 3, 49 | "name": "Leaf 3", 50 | "state": Object { 51 | "deletable": true, 52 | "expanded": false, 53 | "favorite": true, 54 | }, 55 | }, 56 | Object { 57 | "id": 4, 58 | "name": "Leaf 4", 59 | }, 60 | ], 61 | "id": 2, 62 | "name": "Leaf 2", 63 | "state": Object { 64 | "deletable": true, 65 | "expanded": true, 66 | }, 67 | } 68 | `; 69 | 70 | exports[`TreeState getNodeAt should get a for an existing rowId: 3rd row 1`] = ` 71 | Object { 72 | "children": Array [ 73 | Object { 74 | "id": "c-3", 75 | "name": "Leaf 3 Child", 76 | "state": Object {}, 77 | }, 78 | ], 79 | "id": 3, 80 | "name": "Leaf 3", 81 | "state": Object { 82 | "deletable": true, 83 | "expanded": false, 84 | "favorite": true, 85 | }, 86 | } 87 | `; 88 | 89 | exports[`TreeState getNodeAt should get a for an existing rowId: 7th row 1`] = ` 90 | Object { 91 | "id": "z", 92 | "name": "Leaf z", 93 | "state": Object { 94 | "deletable": true, 95 | "favorite": true, 96 | }, 97 | } 98 | `; 99 | 100 | exports[`TreeState getNodeDeepness should fail with a custom error when supplied rowId does not exist 1`] = `"Tried to get node at row \\"40\\" but got nothing, the tree are 7 visible rows"`; 101 | -------------------------------------------------------------------------------- /src/__tests__/Tree/render.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('react-virtualized'); 2 | 3 | import React from 'react'; 4 | import {render, cleanup, fireEvent} from 'react-testing-library'; 5 | 6 | import Tree from '../..'; 7 | import {Nodes} from '../../../testData/sampleTree'; 8 | 9 | class Example extends React.Component { 10 | state = { 11 | nodes: Nodes, 12 | }; 13 | 14 | expandAllNodes = nodes => { 15 | return nodes.map(n => { 16 | let children; 17 | 18 | if (n.children) { 19 | children = this.expandAllNodes(n.children); 20 | } 21 | 22 | const state = n.state || {}; 23 | 24 | return { 25 | ...n, 26 | children, 27 | state: { 28 | ...state, 29 | expanded: true, 30 | }, 31 | }; 32 | }); 33 | }; 34 | 35 | removePairRootPositionedNodes = () => { 36 | const nodes = this.state.nodes.filter((n, i) => i % 2 !== 0); 37 | 38 | this.setState({nodes}); 39 | }; 40 | 41 | render() { 42 | return ( 43 |
44 |
65 | ); 66 | } 67 | } 68 | 69 | describe('Tree rendering', () => { 70 | afterEach(cleanup); 71 | 72 | test('should render as expected on mount', () => { 73 | const {getByTestId} = render(); 74 | 75 | expect(getByTestId('tree')).toMatchSnapshot(); 76 | }); 77 | 78 | test('should render as expected on updates', () => { 79 | const {getByTestId} = render(); 80 | 81 | fireEvent.click(getByTestId('expand-all')); 82 | 83 | expect(getByTestId('tree')).toMatchSnapshot('all-expanded'); 84 | 85 | fireEvent.click(getByTestId('remove-in-pair-root-pos')); 86 | 87 | expect(getByTestId('tree')).toMatchSnapshot('remove-in-pair-root-pos'); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/renderers/__tests__/Deletable.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, cleanup, fireEvent} from 'react-testing-library'; 3 | 4 | import Deletable from '../Deletable'; 5 | import {KEY_CODES} from '../../eventWrappers'; 6 | import {deleteNode} from '../../selectors/nodes'; 7 | 8 | describe('renderers Deletable', () => { 9 | afterEach(cleanup); 10 | 11 | const setup = (state = {deletable: true}, extraProps = {}) => { 12 | const baseProps = { 13 | onChange: jest.fn(), 14 | node: { 15 | id: 1, 16 | name: 'Node 1', 17 | state, 18 | deepness: 0, 19 | children: [{}], 20 | }, 21 | measure: jest.fn(), 22 | index: 1, 23 | }; 24 | 25 | const props = {...baseProps, ...extraProps}; 26 | 27 | return { 28 | ...render(), 29 | props, 30 | }; 31 | }; 32 | 33 | describe('when it is deletable', () => { 34 | it('should render the delete icon with the supplied className', () => { 35 | const {container, props} = setup( 36 | {deletable: true}, 37 | { 38 | iconsClassNameMap: { 39 | delete: 'delete', 40 | }, 41 | }, 42 | ); 43 | 44 | expect(container.querySelector(`.${props.iconsClassNameMap.delete}`)).not.toBeNull(); 45 | expect(container.querySelector(`.mi.mi-delete`)).toBeNull(); 46 | }); 47 | 48 | it('should render the delete icon with the default className when one is not supplied', () => { 49 | const {container} = setup(); 50 | 51 | expect(container.querySelector(`.mi.mi-delete`)).not.toBeNull(); 52 | }); 53 | 54 | it('clicking should call onChange with the correct params', () => { 55 | const {container, props} = setup(); 56 | 57 | fireEvent.click(container.querySelector('i')); 58 | 59 | expect(props.onChange).toHaveBeenCalledWith({...deleteNode(props.node), index: props.index}); 60 | }); 61 | 62 | it('pressing enter should call onChange with the correct params', () => { 63 | const {props, container} = setup(); 64 | 65 | fireEvent.keyDown(container.querySelector('i'), {keyCode: KEY_CODES.Enter}); 66 | 67 | expect(props.onChange).toHaveBeenCalledWith({...deleteNode(props.node), index: props.index}); 68 | }); 69 | }); 70 | 71 | describe('when it is not deletable', () => { 72 | it('should net render delete icon', () => { 73 | const {container} = setup({deletable: false}); 74 | 75 | expect(container.querySelector('i')).toBeNull(); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | #### Caption 2 | - :lollipop: - Refactor/improvment 3 | - :rocket: - Feature 4 | - :bug: - Bug fix 5 | 6 | ### 2.0.1 7 | - :lollipop: Nwb update to the latest release ([diogofcunha](https://github.com/diogofcunha) - [#41](https://github.com/diogofcunha/react-virtualized-tree/pull/41)) 8 | 9 | - :lollipop: React 16.3 update ([diogofcunha](https://github.com/diogofcunha) - [#40](https://github.com/diogofcunha/react-virtualized-tree/pull/40)) 10 | 11 | - :lollipop:/:rocket: Optimize bundle ([diogofcunha](https://github.com/diogofcunha) - [#38](https://github.com/diogofcunha/react-virtualized-tree/pull/38)) 12 | > react-dom, react-virtualized and full lodash were included in the build. umd module size 322.58 KB (:worried:) to 28.05 KB (:relaxed:) 13 | 14 | - :lollipop: Add a basic screenshot. Update the example link. ([justinlawrence](https://github.com/justinlawrence) - [#32](https://github.com/diogofcunha/react-virtualized-tree/pull/32)) 15 | 16 | - :lollipop: Store filter mappings to improve future update performance. ([diogofcunha](https://github.com/diogofcunha) - [#28](https://github.com/diogofcunha/react-virtualized-tree/pull/28)) 17 | 18 | - :lollipop: Internal renames. ([diogofcunha](https://github.com/diogofcunha) - [#27](https://github.com/diogofcunha/react-virtualized-tree/pull/27)) 19 | 20 | - :rocket: Exposing Left Margin as a User Modifiable Property. ([blakfeld](https://github.com/blakfeld) - [#25](https://github.com/diogofcunha/react-virtualized-tree/pull/25)) 21 | > Exposing tree node left margin as a prop 22 | 23 | - :bug: Fixing typo. ([blakfeld](https://github.com/blakfeld) - [#24](https://github.com/diogofcunha/react-virtualized-tree/pull/24)) 24 | > Fixed typo udpateNode 25 | 26 | 27 | - :rocket: Dynamic node height. ([diogofcunha](https://github.com/diogofcunha) - [#22](https://github.com/diogofcunha/react-virtualized-tree/pull/22)) 28 | > Using CellMeasurer to have dynamic row heigths, example added at https://diogofcunha.github.io/react-virtualized-tree/#/examples/node-measure 29 | 30 | - :lollipop:/:rocket: Improve node replace algorithm. ([diogofcunha](https://github.com/diogofcunha) - [#20](https://github.com/diogofcunha/react-virtualized-tree/pull/20)) 31 | 32 | > Refactor of replacement algorithm, with 230k nodes, replacing a node ~500ms (:worried:) to ~10ms (:relaxed:) 33 | 34 | 35 | ##### 1.2.5 36 | 37 | - Add typescript type definitions. ([diogofcunha](https://github.com/diogofcunha) - [#19](https://github.com/diogofcunha/react-virtualized-tree/pull/19)) 38 | - Node ids can now be strings. ([diogofcunha](https://github.com/diogofcunha) - [#16](https://github.com/diogofcunha/react-virtualized-tree/pull/16)) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-virtualized-tree 2 | 3 | [![Travis][build-badge]][build] 4 | [![npm package][npm-badge]][npm] 5 | [![Coveralls][coveralls-badge]][coveralls] 6 | [![Gitter](https://img.shields.io/gitter/room/nwjs/nw.js.svg)](https://gitter.im/react-virtualized-tree/Lobby) 7 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 8 | 9 | [build-badge]: https://img.shields.io/travis/diogofcunha/react-virtualized-tree/master.png?style=flat-square 10 | [build]: https://travis-ci.org/diogofcunha/react-virtualized-tree 11 | [npm-badge]: https://img.shields.io/npm/v/react-virtualized-tree.png?style=flat-square 12 | [npm]: https://www.npmjs.com/package/react-virtualized-tree 13 | [coveralls-badge]: https://img.shields.io/coveralls/diogofcunha/react-virtualized-tree/master.png?style=flat-square 14 | [coveralls]: https://coveralls.io/github/diogofcunha/react-virtualized-tree 15 | 16 |
17 | 18 |
19 | 20 | ## Introduction 21 | 22 | **react-virtualized-tree** is a tree view react library built on top of [react-virtualized](https://bvaughn.github.io/react-virtualized/#/components/List) 23 | 24 | Its main goal is to display tree like data in a beautiful and fast way. Being a reactive library it uses children functions to achieve maximum extensibility. The core idea behind it is that anyone using it is enable to create a tree as they intent just by rendering their own components or components exported by the tree. 25 | 26 | Demo and docs can be found [in here](https://diogofcunha.github.io/react-virtualized-tree/#/examples/basic-tree). 27 | 28 | ## Installation 29 | 30 | You can install via npm or yarn. 31 | `npm i react-virtualized-tree --save` 32 | 33 | or 34 | 35 | `yarn add react-virtualized-tree` 36 | 37 | To get the basic styles for free you need to import react-virtualized styles only once. 38 | 39 | ``` 40 | import 'react-virtualized/styles.css' 41 | import 'react-virtualized-tree/lib/main.css' 42 | ``` 43 | 44 | If you want to use the icons in the default renderers do the same for material icons. 45 | 46 | `import 'material-icons/css/material-icons.css'` 47 | 48 | ## Usage 49 | 50 | To use the standalone tree 51 | 52 | `import Tree from 'react-virtualized-tree'` 53 | 54 | To use the FilteringContainer 55 | 56 | `import { FilteringContainer } from 'react-virtualized-tree'` 57 | 58 | ## Dependencies 59 | 60 | Most react-virtualized-tree Dependencies are managed internally, the only required peerDependencies are **react**, **react-dom** and **react-virtualized**. 61 | -------------------------------------------------------------------------------- /src/state/TreeStateModifiers.js: -------------------------------------------------------------------------------- 1 | import { 2 | getFlattenedTreePaths, 3 | doesChangeAffectFlattenedTree, 4 | isNodeExpanded, 5 | nodeHasChildren, 6 | } from '../selectors/getFlattenedTree'; 7 | import TreeState, {State} from './TreeState'; 8 | import {replaceNodeFromTree, deleteNodeFromTree} from '../selectors/nodes'; 9 | 10 | /** 11 | * @callback setNode 12 | * @param {Node} node - current node value 13 | * @return {Node} The updated node 14 | */ 15 | 16 | /** 17 | * Set of Tree State Modifiers 18 | */ 19 | export default class TreeStateModifiers { 20 | /** 21 | * Given a state, finds a node at a certain row index. 22 | * @param {State} state - The current state 23 | * @param {number} index - The visible row index 24 | * @param {setNode|Node} nodeUpdate - A function to update the node 25 | * @return {State} An internal state representation 26 | */ 27 | static editNodeAt = (state, index, nodeUpdate) => { 28 | const node = TreeState.getNodeAt(state, index); 29 | const updatedNode = typeof nodeUpdate === 'function' ? nodeUpdate(node) : nodeUpdate; 30 | const flattenedTree = [...state.flattenedTree]; 31 | const flattenedNodeMap = flattenedTree[index]; 32 | const parents = flattenedNodeMap.slice(0, flattenedNodeMap.length - 1); 33 | 34 | if (doesChangeAffectFlattenedTree(node, updatedNode)) { 35 | const numberOfVisibleDescendants = TreeState.getNumberOfVisibleDescendants(state, index); 36 | 37 | if (isNodeExpanded(updatedNode)) { 38 | const updatedNodeSubTree = getFlattenedTreePaths([updatedNode], parents); 39 | 40 | flattenedTree.splice(index + 1, 0, ...updatedNodeSubTree.slice(1)); 41 | } else { 42 | flattenedTree.splice(index + 1, numberOfVisibleDescendants); 43 | } 44 | } 45 | 46 | const tree = replaceNodeFromTree(state.tree, {...updatedNode, parents}); 47 | 48 | return new State(tree, flattenedTree); 49 | }; 50 | 51 | /** 52 | * Given a state, deletes a node 53 | * @param {State} state - The current state 54 | * @param {number} index - The visible row index 55 | * @return {State} An internal state representation 56 | */ 57 | static deleteNodeAt = (state, index) => { 58 | const node = TreeState.getNodeAt(state, index); 59 | 60 | const flattenedTree = [...state.flattenedTree]; 61 | const flattenedNodeMap = flattenedTree[index]; 62 | const parents = flattenedNodeMap.slice(0, flattenedNodeMap.length - 1); 63 | 64 | const numberOfVisibleDescendants = nodeHasChildren(node) 65 | ? TreeState.getNumberOfVisibleDescendants(state, index) 66 | : 0; 67 | 68 | flattenedTree.splice(index, 1 + numberOfVisibleDescendants); 69 | 70 | const tree = deleteNodeFromTree(state.tree, {...node, parents}); 71 | 72 | return new State(tree, flattenedTree); 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /src/FilteringContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import debounce from 'lodash.debounce'; 4 | import classNames from 'classnames'; 5 | 6 | import DefaultGroupRenderer from './filtering/DefaultGroupRenderer'; 7 | import {Node} from './shapes/nodeShapes'; 8 | import {filterNodes} from './selectors/filtering'; 9 | 10 | const indexByName = searchTerm => ({name}) => { 11 | const upperCaseName = name.toUpperCase(); 12 | const upperCaseSearchTerm = searchTerm.toUpperCase(); 13 | 14 | return upperCaseName.indexOf(upperCaseSearchTerm.trim()) > -1; 15 | }; 16 | 17 | export default class FilteringContainer extends React.Component { 18 | state = { 19 | filterText: '', 20 | filterTerm: '', 21 | }; 22 | 23 | getChildContext = () => { 24 | return {unfilteredNodes: this.props.nodes}; 25 | }; 26 | 27 | static childContextTypes = { 28 | unfilteredNodes: PropTypes.arrayOf(PropTypes.shape(Node)).isRequired, 29 | }; 30 | 31 | static defaultProps = { 32 | debouncer: debounce, 33 | groupRenderer: DefaultGroupRenderer, 34 | indexSearch: indexByName, 35 | }; 36 | 37 | constructor(props) { 38 | super(props); 39 | 40 | this.setFilterTerm = props.debouncer(this.setFilterTerm, 300); 41 | } 42 | 43 | setFilterTerm() { 44 | this.setState(ps => ({filterTerm: ps.filterText})); 45 | } 46 | 47 | handleFilterTextChange = e => { 48 | const filterText = e.target.value; 49 | 50 | this.setState({filterText}); 51 | 52 | this.setFilterTerm(); 53 | }; 54 | 55 | render() { 56 | const {filterTerm, filterText} = this.state; 57 | const { 58 | nodes, 59 | children: treeRenderer, 60 | groups, 61 | selectedGroup, 62 | groupRenderer: GroupRenderer, 63 | onSelectedGroupChange, 64 | indexSearch, 65 | } = this.props; 66 | 67 | const relevantNodes = 68 | groups && selectedGroup && groups[selectedGroup] 69 | ? filterNodes(groups[selectedGroup].filter, nodes) 70 | : {nodes, nodeParentMappings: {}}; 71 | 72 | const {nodes: filteredNodes, nodeParentMappings} = filterTerm 73 | ? filterNodes(indexSearch(filterTerm, relevantNodes.nodes), relevantNodes.nodes) 74 | : relevantNodes; 75 | 76 | return ( 77 |
78 |
79 | 80 |