├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── __mocks__
└── react-virtualized.js
├── config
├── pages.js
└── test
│ ├── cssTransform.js
│ ├── fileTransform.js
│ └── test.js
├── demo
└── src
│ ├── Home.js
│ ├── NavBar.css
│ ├── NavBar.js
│ ├── containers
│ ├── DocumentsContainer.js
│ ├── ExamplesContainer.css
│ └── ExamplesContainer.js
│ ├── docs
│ ├── Doc.js
│ ├── extensions.md
│ ├── filtering.md
│ ├── index.js
│ └── renderers.md
│ ├── examples
│ ├── Basic
│ │ ├── DraggableRenderer.js
│ │ ├── ItemTypes.js
│ │ ├── RendererDragContainer.js
│ │ └── index.js
│ ├── ChangeRenderers.js
│ ├── Extensions.js
│ ├── Filterable.js
│ ├── KeyboardNavigation.js
│ ├── LargeCollection.js
│ ├── NodeMeasure.js
│ ├── Renderers.js
│ ├── WorldCup.js
│ └── index.js
│ ├── index.css
│ ├── index.html
│ ├── index.js
│ └── toolbelt.js
├── index.d.ts
├── nwb.config.js
├── package.json
├── src
├── FilteringContainer.js
├── Tree.js
├── TreeContainer.js
├── UnstableFastTree.js
├── __tests__
│ ├── FilteringContainer.test.js
│ ├── Tree
│ │ ├── __snapshots__
│ │ │ └── render.test.js.snap
│ │ ├── extensions.test.js
│ │ ├── filtering.test.js
│ │ ├── render.test.js
│ │ └── renderers.test.js
│ ├── __snapshots__
│ │ └── FilteringContainer.test.js.snap
│ └── eventWrappers.test.js
├── contants.js
├── eventWrappers.js
├── filtering
│ ├── DefaultGroupRenderer.js
│ └── __tests__
│ │ ├── DefaultGroupRenderer.test.js
│ │ └── __snapshots__
│ │ └── DefaultGroupRenderer.test.js.snap
├── index.js
├── main.css
├── renderers
│ ├── Deletable.js
│ ├── Expandable.js
│ ├── Favorite.js
│ ├── __tests__
│ │ ├── Deletable.test.js
│ │ ├── Expandable.test.js
│ │ └── Favorite.test.js
│ └── index.js
├── selectors
│ ├── __tests__
│ │ ├── __snapshots__
│ │ │ ├── filtering.test.js.snap
│ │ │ ├── getFlattenedTree.test.js.snap
│ │ │ └── nodes.test.js.snap
│ │ ├── filtering.test.js
│ │ ├── getFlattenedTree.test.js
│ │ └── nodes.test.js
│ ├── filtering.js
│ ├── getFlattenedTree.js
│ └── nodes.js
├── setupTests.js
├── shapes
│ ├── nodeShapes.js
│ └── rendererShapes.js
└── state
│ ├── TreeState.js
│ ├── TreeStateModifiers.js
│ └── __tests__
│ ├── TreeState.test.js
│ ├── TreeStateModifiers.test.js
│ └── __snapshots__
│ ├── TreeState.test.js.snap
│ └── TreeStateModifiers.test.js.snap
├── testData
└── sampleTree.js
├── tests
└── NodeDiff.js
└── yarn.lock
/.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
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-virtualized-tree
2 |
3 | [![Travis][build-badge]][build]
4 | [![npm package][npm-badge]][npm]
5 | [![Coveralls][coveralls-badge]][coveralls]
6 | [](https://gitter.im/react-virtualized-tree/Lobby)
7 | [](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 |
--------------------------------------------------------------------------------
/__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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/demo/src/Home.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Segment, Header} from 'semantic-ui-react';
3 |
4 | export default () => (
5 |
6 |
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/demo/src/containers/ExamplesContainer.css:
--------------------------------------------------------------------------------
1 | .jump-to-source {
2 | float: right;
3 | color: #87CEFA;
4 | }
--------------------------------------------------------------------------------
/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 |
37 | {description &&
{description} }
38 |
39 |
40 |
41 |
42 | );
43 | }}
44 | />
45 |
46 |
47 |
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/demo/src/examples/Basic/DraggableRenderer.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import PropTypes from 'prop-types';
3 | import {findDOMNode} from 'react-dom';
4 | import {DragSource, DropTarget} from 'react-dnd';
5 | import ItemTypes from './ItemTypes';
6 | import {Label, Icon} from 'semantic-ui-react';
7 |
8 | const style = {
9 | marginBottom: '.5rem',
10 | cursor: 'move',
11 | width: '50%',
12 | };
13 |
14 | const cardSource = {
15 | beginDrag(props) {
16 | return {
17 | id: props.id,
18 | index: props.index,
19 | };
20 | },
21 | };
22 |
23 | const cardTarget = {
24 | hover(props, monitor, component) {
25 | const dragIndex = monitor.getItem().index;
26 | const hoverIndex = props.index;
27 |
28 | // Don't replace items with themselves
29 | if (dragIndex === hoverIndex) {
30 | return;
31 | }
32 |
33 | // Determine rectangle on screen
34 | const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
35 |
36 | // Get vertical middle
37 | const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
38 |
39 | // Determine mouse position
40 | const clientOffset = monitor.getClientOffset();
41 |
42 | // Get pixels to the top
43 | const hoverClientY = clientOffset.y - hoverBoundingRect.top;
44 |
45 | // Only perform the move when the mouse has crossed half of the items height
46 | // When dragging downwards, only move when the cursor is below 50%
47 | // When dragging upwards, only move when the cursor is above 50%
48 |
49 | // Dragging downwards
50 | if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
51 | return;
52 | }
53 |
54 | // Dragging upwards
55 | if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
56 | return;
57 | }
58 |
59 | // Time to actually perform the action
60 | props.moveRenderer(dragIndex, hoverIndex);
61 |
62 | // Note: we're mutating the monitor item here!
63 | // Generally it's better to avoid mutations,
64 | // but it's good here for the sake of performance
65 | // to avoid expensive index searches.
66 | monitor.getItem().index = hoverIndex;
67 | },
68 | };
69 |
70 | @DropTarget(ItemTypes.RENDERER, cardTarget, connect => ({
71 | connectDropTarget: connect.dropTarget(),
72 | }))
73 | @DragSource(ItemTypes.RENDERER, cardSource, (connect, monitor) => ({
74 | connectDragSource: connect.dragSource(),
75 | isDragging: monitor.isDragging(),
76 | }))
77 | export default class RendererCard extends Component {
78 | static propTypes = {
79 | connectDragSource: PropTypes.func.isRequired,
80 | connectDropTarget: PropTypes.func.isRequired,
81 | index: PropTypes.number.isRequired,
82 | isDragging: PropTypes.bool.isRequired,
83 | id: PropTypes.any.isRequired,
84 | moveRenderer: PropTypes.func.isRequired,
85 | };
86 |
87 | render() {
88 | const {renderer, id, isDragging, connectDragSource, connectDropTarget, handleRendererDeselection} = this.props;
89 | const opacity = isDragging ? 0 : 1;
90 |
91 | const isNodeName = renderer.name === 'NodeNameRenderer';
92 | const name = isNodeName ? 'Node Name' : renderer.name;
93 |
94 | return connectDragSource(
95 | connectDropTarget(
96 |
97 |
98 | {name}
99 | {!isNodeName && }
100 |
101 |
,
102 | ),
103 | );
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/demo/src/examples/Basic/ItemTypes.js:
--------------------------------------------------------------------------------
1 | export default {
2 | RENDERER: 'renderer',
3 | };
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/demo/src/examples/Basic/index.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import reactElementToJSXString from 'react-element-to-jsx-string';
3 | import {Grid, Header, Label, Icon} from 'semantic-ui-react';
4 | import update from 'immutability-helper';
5 |
6 | import Tree from '../../../../src/TreeContainer';
7 | import Renderers from '../../../../src/renderers';
8 | import {Nodes} from '../../../../testData/sampleTree';
9 | import {createEntry} from '../../toolbelt';
10 | import RendererDragContainer from './RendererDragContainer';
11 |
12 | const {Deletable, Expandable, Favorite} = Renderers;
13 |
14 | const NodeNameRenderer = ({node: {name}, children}) => (
15 |
16 | {name}
17 | {children}
18 |
19 | );
20 |
21 | class BasicTree extends Component {
22 | state = {
23 | nodes: Nodes,
24 | availableRenderers: [Expandable, Deletable, Favorite],
25 | selectedRenderers: [Expandable, NodeNameRenderer],
26 | };
27 |
28 | handleRendererMove = (dragIndex, hoverIndex) => {
29 | const {selectedRenderers} = this.state;
30 | const dragCard = selectedRenderers[dragIndex];
31 |
32 | this.setState(
33 | update(this.state, {
34 | selectedRenderers: {
35 | $splice: [[dragIndex, 1], [hoverIndex, 0, dragCard]],
36 | },
37 | }),
38 | );
39 | };
40 |
41 | handleChange = nodes => {
42 | this.setState({nodes});
43 | };
44 |
45 | renderNodeDisplay = (display, props, children = []) => React.createElement(display, props, children);
46 |
47 | createNodeRenderer = (nodeDisplay = this.state.nodeDisplay, props) => {
48 | const [nextNode, ...remainingNodes] = nodeDisplay;
49 |
50 | if (remainingNodes.length === 0) {
51 | return this.renderNodeDisplay(nextNode, props);
52 | }
53 |
54 | return this.renderNodeDisplay(nextNode, props, this.createNodeRenderer(remainingNodes, props));
55 | };
56 |
57 | getRenderedComponentTree = () =>
58 | reactElementToJSXString(this.createNodeRenderer(this.state.selectedRenderers, {node: {name: 'X', id: 0}}))
59 | .split('>')
60 | .filter(c => c)
61 | .map((c, i) => {
62 | const {
63 | selectedRenderers: {length},
64 | } = this.state;
65 | const isClosingTag = i >= length;
66 |
67 | const marginLeft = !isClosingTag ? 10 * i : 10 * (length - 2 - Math.abs(length - i));
68 |
69 | return {c}>
;
70 | });
71 |
72 | handleRendererDeselection = i => () => {
73 | this.setState(({selectedRenderers}) => ({
74 | selectedRenderers: [...selectedRenderers.slice(0, i), ...selectedRenderers.slice(i + 1)],
75 | }));
76 | };
77 |
78 | handleRendererSelection = renderer => () => {
79 | this.setState(({selectedRenderers}) => ({
80 | selectedRenderers: [...selectedRenderers, renderer],
81 | }));
82 | };
83 |
84 | render() {
85 | const renderersAvailableForAdd = this.state.availableRenderers.filter(
86 | r => this.state.selectedRenderers.indexOf(r) === -1,
87 | );
88 |
89 | return (
90 |
91 |
92 |
93 |
94 |
95 |
96 | {renderersAvailableForAdd.map((r, i) => {
97 | return (
98 |
99 | {r.name}
100 |
101 |
102 | );
103 | })}
104 |
105 |
106 |
107 |
108 |
109 |
110 | {({style, ...p}) => {this.createNodeRenderer(this.state.selectedRenderers, p)}
}
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
125 |
126 |
127 |
128 |
129 |
130 | {this.getRenderedComponentTree()}
131 |
132 |
133 |
134 | );
135 | }
136 | }
137 |
138 | export default createEntry(
139 | 'basic-tree',
140 | 'Basic/index',
141 | 'Basic Tree',
142 |
143 |
144 | A tree that enables favorite toogle, expansion and deletion, this example only makes use of the default renderers
145 |
146 |
,
147 | BasicTree,
148 | );
149 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/demo/src/examples/Extensions.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import classNames from 'classnames';
3 |
4 | import Tree from '../../../src/TreeContainer';
5 | import Renderers from '../../../src/renderers';
6 | import {Nodes} from '../../../testData/sampleTree';
7 | import {createEntry} from '../toolbelt';
8 |
9 | const {Expandable} = Renderers;
10 |
11 | const SELECT = 3;
12 |
13 | const Selection = ({node, children, onChange}) => {
14 | const {state: {selected} = {}} = node;
15 | const className = classNames({
16 | 'mi mi-check-box': selected,
17 | 'mi mi-check-box-outline-blank': !selected,
18 | });
19 |
20 | return (
21 |
22 |
25 | onChange({
26 | node: {
27 | ...node,
28 | state: {
29 | ...(node.state || {}),
30 | selected: !selected,
31 | },
32 | },
33 | type: SELECT,
34 | })
35 | }
36 | />
37 | {children}
38 |
39 | );
40 | };
41 |
42 | class Extensions extends Component {
43 | state = {
44 | nodes: Nodes,
45 | };
46 |
47 | handleChange = nodes => {
48 | this.setState({nodes});
49 | };
50 |
51 | selectNodes = (nodes, selected) =>
52 | nodes.map(n => ({
53 | ...n,
54 | children: n.children ? this.selectNodes(n.children, selected) : [],
55 | state: {
56 | ...n.state,
57 | selected,
58 | },
59 | }));
60 |
61 | nodeSelectionHandler = (nodes, updatedNode) =>
62 | nodes.map(node => {
63 | if (node.id === updatedNode.id) {
64 | return {
65 | ...updatedNode,
66 | children: node.children ? this.selectNodes(node.children, updatedNode.state.selected) : [],
67 | };
68 | }
69 |
70 | if (node.children) {
71 | return {...node, children: this.nodeSelectionHandler(node.children, updatedNode)};
72 | }
73 |
74 | return node;
75 | });
76 |
77 | render() {
78 | return (
79 |
88 | {({style, node, ...rest}) => (
89 |
90 |
91 |
92 | {node.name}
93 |
94 |
95 |
96 | )}
97 |
98 | );
99 | }
100 | }
101 |
102 | export default createEntry(
103 | 'extensions',
104 | 'Extensions',
105 | 'Extending behaviour',
106 |
107 |
108 | A good example of a possible extension is creating a new handler to select nodes that automatically selects all
109 | the children nodes.
110 |
111 |
112 |
113 | By injecting extensions
prop with an update type handler for node selection that can be achieved.
114 |
115 |
,
116 | Extensions,
117 | );
118 |
--------------------------------------------------------------------------------
/demo/src/examples/Filterable.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import classNames from 'classnames';
3 | import {Checkbox} from 'semantic-ui-react';
4 |
5 | import Tree from '../../../src/TreeContainer';
6 | import Renderers from '../../../src/renderers';
7 | import {createEntry, constructTree} from '../toolbelt';
8 | import FilteringContainer from '../../../src/FilteringContainer';
9 | import Favorite from '../../../src/renderers/Favorite';
10 |
11 | const {Expandable} = Renderers;
12 |
13 | const MAX_DEEPNESS = 3;
14 | const MAX_NUMBER_OF_CHILDREN = 4;
15 | const MIN_NUMBER_OF_PARENTS = 5;
16 |
17 | const Nodes = constructTree(MAX_DEEPNESS, MAX_NUMBER_OF_CHILDREN, MIN_NUMBER_OF_PARENTS);
18 |
19 | const EXPANDED = 'EXPANDED';
20 |
21 | class Filterable extends Component {
22 | state = {
23 | nodes: Nodes,
24 | selectedGroup: EXPANDED,
25 | groupsEnabled: true,
26 | };
27 |
28 | get _groupProps() {
29 | return this.state.groupsEnabled
30 | ? {
31 | groups: {
32 | ALL: {
33 | name: 'All',
34 | filter: node => true,
35 | },
36 | [EXPANDED]: {
37 | name: 'Expanded',
38 | filter: node => (node.state || {}).expanded,
39 | },
40 | FAVORITES: {
41 | name: 'Favorites',
42 | filter: node => (node.state || {}).favorite,
43 | },
44 | },
45 | selectedGroup: this.state.selectedGroup,
46 | onSelectedGroupChange: this.handleSelectedGroupChange,
47 | }
48 | : {};
49 | }
50 |
51 | handleChange = nodes => {
52 | this.setState({nodes});
53 | };
54 |
55 | handleSelectedGroupChange = selectedGroup => {
56 | this.setState({selectedGroup});
57 | };
58 |
59 | handleGroupsToogle = () => {
60 | this.setState({groupsEnabled: !this.state.groupsEnabled});
61 | };
62 |
63 | render() {
64 | return (
65 |
66 |
73 |
74 | {({nodes}) => (
75 |
76 |
77 | {({style, node, ...rest}) => (
78 |
79 |
80 |
81 | {node.name}
82 |
83 |
84 |
85 | )}
86 |
87 |
88 | )}
89 |
90 |
91 | );
92 | }
93 | }
94 |
95 | export default createEntry(
96 | 'filterable',
97 | 'Filterable',
98 | 'Filterable tree',
99 |
100 |
When working with big data collections filtering can be very handy.
101 |
102 |
103 | By wrapping the Tree with the FilteringContainer
your tree will only recieve the nodes it needs to
104 | render.
105 |
106 |
,
107 | Filterable,
108 | );
109 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/examples/NodeMeasure.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 |
3 | import Tree from '../../../src/TreeContainer';
4 | import Renderers from '../../../src/renderers';
5 | import {createEntry} from '../toolbelt';
6 | import {getNodeRenderOptions} from '../../../src/selectors/nodes';
7 |
8 | const {Expandable} = Renderers;
9 |
10 | const Nodes = [
11 | {
12 | id: 'arg',
13 | name: 'Argentina',
14 | children: [
15 | {
16 | id: 'messi',
17 | name: 'Leo Messi',
18 | children: [{id: 'messi-desc', name: ''}],
19 | },
20 | {
21 | id: 'maradona',
22 | name: 'Diego Maradona',
23 | children: [{id: 'maradona-desc', name: ''}],
24 | },
25 | ],
26 | },
27 | {
28 | id: 'pt',
29 | name: 'Portugal',
30 | children: [
31 | {
32 | id: 'cr',
33 | name: 'Cristiano Ronaldo',
34 | children: [{id: 'cr-desc', name: ''}],
35 | },
36 | {
37 | id: 'figo',
38 | name: 'Luis Figo',
39 | children: [{id: 'figo-desc', name: ''}],
40 | },
41 | ],
42 | },
43 | {
44 | id: 'br',
45 | name: 'Brazil',
46 | children: [
47 | {
48 | id: 'r',
49 | name: 'Ronaldo',
50 | children: [{id: 'r-desc', name: ''}],
51 | },
52 | {
53 | id: 'r10',
54 | name: 'Ronaldinho',
55 | children: [{id: 'r10-desc', name: ''}],
56 | },
57 | {
58 | id: 'pele',
59 | name: 'Pele',
60 | children: [{id: 'pele-desc', name: ''}],
61 | },
62 | ],
63 | },
64 | {
65 | id: 'fr',
66 | name: 'France',
67 | children: [
68 | {
69 | id: 'z',
70 | name: 'Zinedine Zidane',
71 | children: [{id: 'z-desc', name: ''}],
72 | },
73 | {
74 | id: 'pl',
75 | name: 'Michel Platini',
76 | children: [{id: 'pl-desc', name: ''}],
77 | },
78 | ],
79 | },
80 | ];
81 |
82 | const DESCRIPTIONS = {
83 | fr:
84 | 'France (French: [fʁɑ̃s]), officially the French Republic (French: République française, pronounced [ʁepyblik fʁɑ̃sɛz]), is a country whose territory consists of metropolitan France in western Europe, as well as several overseas regions and territories.[XIII] The metropolitan area of France extends from the Mediterranean Sea to the English Channel and the North Sea, and from the Rhine to the Atlantic Ocean. The overseas territories include French Guiana in South America and several islands in the Atlantic, Pacific and Indian oceans. The countrys 18 integral regions (five of which are situated overseas) span a combined area of 643,801 square kilometres (248,573 sq mi) which, as of October 2017, has a population of 67.15 million people.[10] France is a unitary semi-presidential republic with its capital in Paris, the countrys largest city and main cultural and commercial centre. Other major urban centres include Marseille, Lyon, Lille, Nice, Toulouse and Bordeaux.',
85 | arg:
86 | 'Argentina (/ˌɑːrdʒənˈtiːnə/ (About this sound listen); Spanish: [aɾxenˈtina]), officially the Argentine Republic[A] (Spanish: República Argentina), is a federal republic located mostly in the southern half of South America. Sharing the bulk of the Southern Cone with its neighbor Chile to the west, the country is also bordered by Bolivia and Paraguay to the north, Brazil to the northeast, Uruguay and the South Atlantic Ocean to the east, and the Drake Passage to the south. With a mainland area of 2,780,400 km2 (1,073,500 sq mi),[B] Argentina is the eighth-largest country in the world, the second largest in Latin America, and the largest Spanish-speaking nation. It is subdivided into twenty-three provinces (Spanish: provincias, singular provincia) and one autonomous city (ciudad autónoma), Buenos Aires, which is the federal capital of the nation (Spanish: Capital Federal) as decided by Congress.[11] The provinces and the capital have their own constitutions, but exist under a federal system. Argentina claims sovereignty over part of Antarctica, the Falkland Islands (Spanish: Islas Malvinas), and South Georgia and the South Sandwich Islands.',
87 | pt:
88 | 'Portugal (Portuguese: [puɾtuˈɣaɫ]), officially the Portuguese Republic (Portuguese: República Portuguesa ,[note 1] is a sovereign state located mostly on the Iberian Peninsula in southwestern Europe. It is the westernmost country of mainland Europe, being bordered to the west and south by the Atlantic Ocean and to the north and east by Spain. Its territory also includes the Atlantic archipelagos of the Azores and Madeira, both autonomous regions with their own regional governments. At 1.7 million km2, its Exclusive Economic Zone is the 3rd largest in the European Union and the 11th largest in the world.[10]',
89 | br:
90 | 'Brazil (/brəˈzɪl/ (About this sound listen); Portuguese: Brasil [bɾaˈziw][10]), officially the Federative Republic of Brazil (Portuguese: República Federativa do Brasil, About this sound listen (help·info)[11]), is the largest country in both South America and Latin America. At 8.5 million square kilometers (3.2 million square miles)[12] and with over 208 million people, Brazil is the worlds fifth-largest country by area and the sixth-most populous. The capital is Brasília, and the most-populated city is São Paulo. It is the largest country to have Portuguese as an official language and the only one in the Americas.[13][14] Bounded by the Atlantic Ocean on the east, Brazil has a coastline of 7,491 kilometers (4,655 mi).[15] It borders all other South American countries except Ecuador and Chile and covers 47.3% of the continents land area.[16] Its Amazon River basin includes a vast tropical forest, home to diverse wildlife, a variety of ecological systems, and extensive natural resources spanning numerous protected habitats.[15] This unique environmental heritage makes Brazil one of 17 megadiverse countries, and is the subject of significant global interest and debate regarding deforestation and environmental protection.',
91 | messi:
92 | 'Lionel Andrés Messi Cuccittini[note 1] (Spanish pronunciation: [ljoˈnel anˈdɾez ˈmesi] (About this sound listen);[A] born 24 June 1987) is an Argentine professional footballer who plays as a forward for Spanish club FC Barcelona and the Argentina national team. Often considered the best player in the world and regarded by many as the greatest of all time, Messi has a record-tying five Ballon dOr awards,[note 2] four of which he won consecutively, and a record-tying four European Golden Shoes.[note 3] He has spent his entire professional career with Barcelona, where he has won 29 trophies, including eight La Liga titles, four UEFA Champions League titles, and five Copas del Rey. Both a prolific goalscorer and a creative playmaker, Messi holds the records for most official goals scored in La Liga (368), a La Liga season (50), a club football season in Europe (73), a calendar year (91), El Clásico (26), as well as those for most assists made in La Liga (146) and the Copa América (11). He has scored over 600 senior career goals for club and country.',
93 | maradona:
94 | 'Diego Armando Maradona Franco (Spanish pronunciation: [ˈdjeɣo maɾaˈðona], born 30 October 1960) is an Argentine retired professional footballer and manager. Many in the sport, including football writers, players, and fans, regard Maradona as the greatest football player of all time.[7][8][9][10][11] He was joint FIFA Player of the 20th Century with Pelé.[12][13]',
95 | cr:
96 | 'Cristiano Ronaldo dos Santos Aveiro GOIH, ComM (European Portuguese: [kɾiʃˈtjɐnu ʁoˈnaɫdu]; born 5 February 1985) is a Portuguese professional footballer who plays as a forward for Spanish club Real Madrid and the Portugal national team. Often considered the best player in the world and widely regarded as the greatest of all time,[note 1] Ronaldo has five Ballon dOr awards,[note 2] the most for a European player and is tied for most all-time. He is the first player in history to win four European Golden Shoes. He has won 25 trophies in his career, including five league titles, four UEFA Champions League titles and one UEFA European Championship. A prolific goalscorer, Ronaldo holds the records for most official goals scored in the top five European leagues (380), the UEFA Champions League (116), the UEFA European Championship (29) and the FIFA Club World Cup (7), as well as most goals scored in a UEFA Champions League season (17). He has scored more than 600 senior career goals for club and country.',
97 | figo:
98 | 'Luís Filipe Madeira Caeiro Figo, OIH (Portuguese pronunciation: [luˈiʃ ˈfiɣu]; born 4 November 1972) is a retired Portuguese footballer. He played as a midfielder for Sporting CP, Barcelona, Real Madrid and Internazionale before retiring on 31 May 2009. He won 127 caps for the Portugal national team, a record at the time but later broken by Cristiano Ronaldo.',
99 | r:
100 | 'Ronaldo Luís Nazário de Lima (locally [ʁoˈnawdu ˈlwiʒ nɐˈzaɾju dʒ ˈɫĩmɐ]; born 18 September 1976[2]), commonly known as Ronaldo, is a retired Brazilian professional footballer who played as a striker. Popularly dubbed "O Fenômeno" (The Phenomenon), he is widely considered to be one of the greatest football players of all time.[3][4][5][6][7] In his prime, he was known for his dribbling at speed, feints, and clinical finishing.',
101 | r10:
102 | 'Ronaldo de Assis Moreira (born 21 March 1980), commonly known as Ronaldinho (Brazilian Portuguese: [ʁonawˈdʒĩɲu]) or Ronaldinho Gaúcho,[note 1] is a Brazilian former professional footballer and ambassador for Spanish club Barcelona.[4] He played mostly as an attacking midfielder, but was also deployed as a forward or a winger. He played the bulk of his career at European clubs Paris Saint-Germain, Barcelona and Milan as well as playing for the Brazilian national team. Often considered one of the best players of his generation and regarded by many as one of the greatest of all time,[note 2] Ronaldinho won two FIFA World Player of the Year awards and a Ballon dOr. He was renowned for his technical skills and creativity; due to his agility, pace and dribbling ability, as well as his use of tricks, overhead kicks, no-look passes and accuracy from free-kicks.',
103 | pele:
104 | 'Edson Arantes do Nascimento (Brazilian Portuguese: [ˈɛtsõ (w)ɐˈɾɐ̃tʃiz du nɐsiˈmẽtu]; born 23 October 1940), known as Pelé ([peˈlɛ]), is a Brazilian retired professional footballer who played as a forward. He is widely regarded as the greatest football player of all time. In 1999, he was voted World Player of the Century by the International Federation of Football History & Statistics (IFFHS). That same year, Pelé was elected Athlete of the Century by the International Olympic Committee. According to the IFFHS, Pelé is the most successful league goal-scorer in the world, scoring 1281 goals in 1363 games, which included unofficial friendlies and tour games. During his playing days, Pelé was for a period the best-paid athlete in the world.',
105 | z:
106 | 'Zinedine Yazid Zidane O.L.H., A.O.M.N. (French pronunciation: [zinedin zidan], born 23 June 1972), nicknamed "Zizou", is a French retired professional footballer and current manager of Real Madrid. He played as an attacking midfielder for the France national team, Cannes, Bordeaux, Juventus and Real Madrid.[3][4] An elite playmaker, renowned for his elegance, vision, ball control and technique, Zidane was named the best European footballer of the past 50 years in the UEFA Golden Jubilee Poll in 2004.[5] He is widely regarded as one of the greatest players of all time',
107 | pl:
108 | 'Michel François Platini (born 21 June 1955) is a French former football player, manager and administrator. Nicknamed Le Roi (The King) for his ability and leadership, he is regarded as one of the greatest footballers of all time. Platini won the Ballon dOr three times, in 1983, 1984 and 1985,[3] and came sixth in the FIFA Player of the Century vote.[4] In recognition of his achievements, he was named Chevalier of the Legion of Honour in 1985 and became Officier in 1988.',
109 | };
110 |
111 | class FootballPlayerRenderer extends React.Component {
112 | componentDidMount() {
113 | this.props.measure();
114 | }
115 |
116 | render() {
117 | const {node, children} = this.props;
118 | const {id, name} = node;
119 | const {isExpanded} = getNodeRenderOptions(node);
120 |
121 | return (
122 |
123 | {children}
124 | {name}
125 | {isExpanded && {DESCRIPTIONS[id]}
}
126 |
127 | );
128 | }
129 | }
130 |
131 | class NodeMeasure extends Component {
132 | state = {
133 | nodes: Nodes,
134 | };
135 |
136 | handleChange = nodes => {
137 | this.setState({nodes});
138 | };
139 |
140 | render() {
141 | return (
142 |
143 | {({style, ...p}) => (
144 |
145 |
146 |
147 |
148 |
149 | )}
150 |
151 | );
152 | }
153 | }
154 |
155 | export default createEntry(
156 | 'node-measure',
157 | 'NodeMeasure',
158 | 'Nodes with auto measure',
159 |
160 |
All cells in react-virtualized-tree implement react-virtualized's CellMeasurer
161 |
162 | All nodes receive a measure prop that can be used to measure nodes with different heights like what happens in
163 | this example
164 |
165 |
,
166 | NodeMeasure,
167 | );
168 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/demo/src/examples/WorldCup.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import 'flag-icon-css/css/flag-icon.min.css';
3 |
4 | import Tree from '../../../src/TreeContainer';
5 | import Renderers from '../../../src/renderers';
6 | import {createEntry} from '../toolbelt';
7 |
8 | const {Expandable} = Renderers;
9 |
10 | const groups = {
11 | A: [0, 1, 2, 3],
12 | B: [4, 5, 6, 7],
13 | C: [8, 9, 10, 11],
14 | D: [12, 13, 14, 15],
15 | E: [16, 17, 18, 19],
16 | F: [20, 21, 22, 23],
17 | G: [24, 25, 26, 27],
18 | H: [28, 29, 30, 31],
19 | };
20 |
21 | const countries = {
22 | 0: {name: 'Russia', flag: 'RU'},
23 | 1: {name: 'Saudi Arabia', flag: 'SA'},
24 | 2: {name: 'Egypt', flag: 'EG'},
25 | 3: {name: 'Uruguay', flag: 'UY'},
26 | 4: {name: 'Portugal', flag: 'PT'},
27 | 5: {name: 'Spain', flag: 'ES'},
28 | 6: {name: 'Morocco', flag: 'MA'},
29 | 7: {name: 'Iran', flag: 'IR'},
30 | 8: {name: 'France', flag: 'FR'},
31 | 9: {name: 'Australia', flag: 'AU'},
32 | 10: {name: 'Peru', flag: 'PE'},
33 | 11: {name: 'Denmark', flag: 'DK'},
34 | 12: {name: 'Argentina', flag: 'AR'},
35 | 13: {name: 'Iceland', flag: 'IS'},
36 | 14: {name: 'Croatia', flag: 'HR'},
37 | 15: {name: 'Nigeria', flag: 'NG'},
38 | 16: {name: 'Brazil', flag: 'BR'},
39 | 17: {name: 'Switzerland', flag: 'CH'},
40 | 18: {name: 'Costa Rica', flag: 'CR'},
41 | 19: {name: 'Serbia', flag: 'RS'},
42 | 20: {name: 'Germany', flag: 'DE'},
43 | 21: {name: 'Mexico', flag: 'MX'},
44 | 22: {name: 'Sweden', flag: 'SE'},
45 | 23: {name: 'South Korea', flag: 'KR'},
46 | 24: {name: 'Belgium', flag: 'BE'},
47 | 25: {name: 'Panama', flag: 'PA'},
48 | 26: {name: 'Tunisia', flag: 'TN'},
49 | 27: {name: 'England', flag: 'GB'},
50 | 28: {name: 'Poland', flag: 'PL'},
51 | 29: {name: 'Senegal', flag: 'SN'},
52 | 30: {name: 'Colombia', flag: 'CO'},
53 | 31: {name: 'Japan', flag: 'JP'},
54 | };
55 |
56 | const worldCup = Object.keys(groups).reduce((wc, g) => {
57 | const groupCountries = groups[g];
58 |
59 | const group = {
60 | id: Math.random(),
61 | name: `Group ${g}`,
62 | state: {
63 | expanded: true,
64 | },
65 | children: groupCountries.map(gc => ({
66 | id: gc,
67 | name: countries[gc].name,
68 | })),
69 | };
70 |
71 | return [...wc, group];
72 | }, []);
73 |
74 | class WorldCupExample extends Component {
75 | state = {
76 | nodes: worldCup,
77 | };
78 |
79 | handleChange = nodes => {
80 | this.setState({nodes});
81 | };
82 |
83 | render() {
84 | return (
85 |
86 | {({style, node, ...rest}) => {
87 | const country = countries[node.id] && countries[node.id].flag.toLowerCase();
88 |
89 | return (
90 |
91 |
92 | {country && }
93 |
99 | {node.name}
100 |
101 |
102 |
103 | );
104 | }}
105 |
106 | );
107 | }
108 | }
109 |
110 | export default createEntry(
111 | 'world-cup',
112 | 'WorldCup',
113 | 'World cup groups',
114 |
115 |
116 | FIFA world cup is back in 2018, in this special example the tree view is used to display the group stage draw
117 | results!
118 |
119 |
Let the best team win.
120 |
,
121 | WorldCupExample,
122 | );
123 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/demo/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | // Type definitions for react-virtualized-tree
2 | // Definitions by: Diogo Cunha
3 |
4 | import * as React from 'react';
5 |
6 | type NodeId = number | string;
7 |
8 | interface BasicNode {
9 | id: NodeId;
10 | name: string;
11 | state?: {[stateKey: string]: any};
12 | }
13 |
14 | export interface Node extends BasicNode {
15 | children?: BasicNode[];
16 | }
17 |
18 | export interface FlattenedNode extends Node {
19 | deepness: number;
20 | parents: number[];
21 | }
22 |
23 | interface NodeAction {
24 | type: string;
25 | node: FlattenedNode;
26 | }
27 |
28 | type onChange = (nodes: Node[], node: Node) => Node[];
29 |
30 | export interface Extensions {
31 | updateTypeHandlers: {[type: number]: onChange};
32 | }
33 |
34 | export interface TreeProps {
35 | extensions?: Extensions;
36 | nodes: Node[];
37 | onChange: (nodes: Node[]) => void;
38 | children: (props: RendererProps) => JSX.Element;
39 | nodeMarginLeft?: number;
40 | width?: number;
41 | scrollToId?: number;
42 | scrollToAlignment?: string;
43 | }
44 |
45 | export default class Tree extends React.Component {}
46 |
47 | export type Omit = Pick>;
48 |
49 | export interface RendererProps {
50 | measure: () => void;
51 | index: number;
52 | onChange: (updateParams: NodeAction) => void;
53 | node: FlattenedNode;
54 | iconsClassNameMap?: T;
55 | style: React.CSSProperties;
56 | children?: React.ReactNode;
57 | }
58 |
59 | export type InjectedRendererProps = Omit, 'iconsClassNameMap'>;
60 | export type CustomRendererProps = Omit, 'style'>;
61 |
62 | type DeletableRenderProps = CustomRendererProps<{delete?: string}>;
63 |
64 | type ExpandableRenderProps = CustomRendererProps<{
65 | expanded?: string;
66 | collapsed?: string;
67 | lastChild?: string;
68 | }>;
69 |
70 | type FavoriteRenderProps = CustomRendererProps<{
71 | favorite?: string;
72 | notFavorite?: string;
73 | }>;
74 |
75 | declare const Deletable: React.SFC;
76 | declare const Expandable: React.SFC;
77 | declare const Favorite: React.SFC;
78 |
79 | interface Renderers {
80 | Deletable: React.SFC;
81 | Expandable: React.SFC;
82 | Favorite: React.SFC;
83 | }
84 |
85 | export const renderers: Renderers;
86 |
87 | export interface Group {
88 | filter: (node: Node) => boolean;
89 | name: string;
90 | }
91 |
92 | interface GroupRendererProps {
93 | onChange: (c: string) => void;
94 | groups: {[g: string]: Group};
95 | selectedGroup: string;
96 | }
97 |
98 | export interface FilteringContainerProps {
99 | nodes: Node[];
100 | children: (params: {nodes: Node[]; nodeParentMappings: {[id: NodeId]: NodeId[]}}) => JSX.Element;
101 | debouncer?: (func: (...p: any[]) => any, timeout: number) => void;
102 | groups?: {[g: string]: Group};
103 | selectedGroup?: string;
104 | groupRenderer?: React.StatelessComponent | React.Component;
105 | onSelectedGroupChange?: (c: string) => void;
106 | indexSearch: (searchTerm: string, nodes: FlattenedNode[]) => (node: FlattenedNode) => boolean;
107 | }
108 |
109 | export class FilteringContainer extends React.Component {}
110 |
111 | export enum UPDATE_TYPE {
112 | ADD = 0,
113 | DELETE = 1,
114 | UPDATE = 2,
115 | }
116 |
117 | interface Constants {
118 | UPDATE_TYPE: UPDATE_TYPE;
119 | }
120 |
121 | export const constants: Constants;
122 |
123 | interface NodeRenderOptions {
124 | hasChildren: boolean;
125 | isExpanded: boolean;
126 | isFavorite: boolean;
127 | isDeletable: boolean;
128 | }
129 |
130 | export enum NODE_CHANGE_OPERATIONS {
131 | CHANGE_NODE = 'CHANGE_NODE',
132 | DELETE_NODE = 'DELETE_NODE',
133 | }
134 |
135 | interface Selectors {
136 | getNodeRenderOptions: (node: FlattenedNode) => NodeRenderOptions;
137 | replaceNodeFromTree: (nodes: Node[], updatedNode: FlattenedNode, operation?: NODE_CHANGE_OPERATIONS) => Node[];
138 | deleteNodeFromTree: (nodes: Node[], nodeToDelete: FlattenedNode) => Node[];
139 | deleteNode: (node: FlattenedNode[]) => NodeAction;
140 | addNode: (node: FlattenedNode[]) => NodeAction;
141 | updateNode: (node: FlattenedNode, state: {[stateKey: string]: any}) => NodeAction;
142 | }
143 |
144 | interface State {
145 | flattenedTree: Array[];
146 | tree: Node[];
147 | }
148 |
149 | export interface TreeState {
150 | getNodeAt: (state: State, index: number) => Node;
151 | getTree: (state: State) => Node[];
152 | createFromTree: (tree: Node[]) => State;
153 | getNumberOfVisibleDescendants: (state: State, index: number) => number;
154 | getNodeDeepness: (state: State, index: number) => number;
155 | }
156 |
157 | export interface TreeStateModifiers {
158 | editNodeAt: (state: State, index: number, updateNode: ((oldNode: Node) => Node) | Node) => State;
159 | deleteNodeAt: (state: State, index: number) => State;
160 | }
161 |
162 | export const selectors: Selectors;
163 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-virtualized-tree",
3 | "version": "2.0.2",
4 | "description": "react-virtualized-tree React component",
5 | "main": "lib/index.js",
6 | "module": "es/index.js",
7 | "files": [
8 | "css",
9 | "es",
10 | "lib",
11 | "umd",
12 | "index.d.ts"
13 | ],
14 | "scripts": {
15 | "prepare": "npm run build",
16 | "prettier": "prettier --write '**/*.js'",
17 | "build": "nwb build-react-component --copy-files",
18 | "clean": "nwb clean-module && nwb clean-demo",
19 | "start": "nwb serve-react-demo",
20 | "test": "node config/test/test.js --env=jsdom",
21 | "test:coverage": "node config/test/test.js --env=jsdom --coverage",
22 | "postpublish": "node config/pages",
23 | "stats:analyzer": "webpack-bundle-analyzer umd/stats.json umd"
24 | },
25 | "dependencies": {
26 | "classnames": "^2.2.5",
27 | "lodash": "^4.17.4",
28 | "lodash.debounce": "^4.0.8",
29 | "lodash.findindex": "^4.6.0",
30 | "lodash.isequal": "^4.5.0",
31 | "lodash.omit": "^4.5.0",
32 | "material-icons": "^0.1.0",
33 | "react-lifecycles-compat": "^3.0.4",
34 | "reselect": "^3.0.1"
35 | },
36 | "peerDependencies": {
37 | "react": "16.x",
38 | "react-dom": "^16.2.0",
39 | "react-virtualized": "^9.13.0"
40 | },
41 | "devDependencies": {
42 | "jest": "^24.8.0",
43 | "babel-jest": "^22.0.4",
44 | "babel-preset-es2015": "^6.24.1",
45 | "babel-preset-react": "^6.24.1",
46 | "babel-preset-react-app": "^3.1.0",
47 | "deep-diff": "^1.0.2",
48 | "deep-freeze": "^0.0.1",
49 | "flag-icon-css": "^2.9.0",
50 | "focus-trap-react": "^5.0.1",
51 | "gh-pages": "^1.1.0",
52 | "husky": "^1.0.0-rc.13",
53 | "immutability-helper": "^2.6.4",
54 | "lint-staged": "^7.2.0",
55 | "nwb": "^0.21.5",
56 | "prettier": "1.13.7",
57 | "prop-types": "^15.6.0",
58 | "react": "^16.3.2",
59 | "react-dnd": "^2.5.4",
60 | "react-dnd-html5-backend": "^2.5.4",
61 | "react-dom": "^16.2.0",
62 | "react-element-to-jsx-string": "^13.1.0",
63 | "react-markdown": "^3.1.4",
64 | "react-router": "^4.2.0",
65 | "react-router-dom": "^4.2.2",
66 | "react-test-renderer": "^16.2.0",
67 | "react-testing-library": "^7.0.0",
68 | "react-virtualized": "^9.18.5",
69 | "semantic-release": "^15.6.3",
70 | "semantic-ui-react": "^0.77.1",
71 | "stats-webpack-plugin": "^0.6.2",
72 | "webpack-bundle-analyzer": "^3.3.2"
73 | },
74 | "jest": {
75 | "collectCoverageFrom": [
76 | "src/**/*.{js,jsx,mjs}",
77 | "!src/**/index.js"
78 | ],
79 | "setupFiles": [
80 | "/src/setupTests.js"
81 | ],
82 | "testMatch": [
83 | "/src/**/__tests__/**/*.{js,jsx,mjs}",
84 | "/src/**/?(*.)(spec|test).{js,jsx,mjs}"
85 | ],
86 | "testEnvironment": "node",
87 | "testURL": "http://localhost",
88 | "transform": {
89 | "^.+\\.(js|jsx|mjs)$": "/node_modules/babel-jest",
90 | "^.+\\.css$": "/config/test/cssTransform.js",
91 | "^(?!.*\\.(js|jsx|mjs|css|json)$)": "/config/test/fileTransform.js"
92 | },
93 | "transformIgnorePatterns": [
94 | "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$"
95 | ]
96 | },
97 | "babel": {
98 | "presets": [
99 | "react-app"
100 | ]
101 | },
102 | "husky": {
103 | "hooks": {
104 | "pre-commit": "lint-staged"
105 | }
106 | },
107 | "lint-staged": {
108 | "**/*.js": [
109 | "prettier --write",
110 | "git add"
111 | ]
112 | },
113 | "prettier": {
114 | "printWidth": 120,
115 | "tabWidth": 2,
116 | "useTabs": false,
117 | "semi": true,
118 | "singleQuote": true,
119 | "trailingComma": "all",
120 | "bracketSpacing": false,
121 | "jsxBracketSameLine": false
122 | },
123 | "author": "Diogo Cunha",
124 | "homepage": "https://diogofcunha.github.io/react-virtualized-tree/",
125 | "license": "MIT",
126 | "repository": "https://github.com/diogofcunha/react-virtualized-tree/",
127 | "keywords": [
128 | "react",
129 | "tree",
130 | "view",
131 | "react-virtualized",
132 | "react-tree",
133 | "tree-view",
134 | "foder-structure",
135 | "react-virtualized-tree",
136 | "react-tree-view",
137 | "react-component"
138 | ]
139 | }
140 |
--------------------------------------------------------------------------------
/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 |
81 | {groups && }
82 |
83 | {treeRenderer({nodes: filteredNodes, nodeParentMappings})}
84 |
85 | );
86 | }
87 | }
88 |
89 | FilteringContainer.propTypes = {
90 | children: PropTypes.func.isRequired,
91 | debouncer: PropTypes.func,
92 | groups: PropTypes.object,
93 | selectedGroup: PropTypes.string,
94 | groupRenderer: PropTypes.func,
95 | onSelectedGroupChange: PropTypes.func,
96 | indexSearch: PropTypes.func,
97 | };
98 |
--------------------------------------------------------------------------------
/src/Tree.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import {AutoSizer, List, CellMeasurerCache, CellMeasurer} from 'react-virtualized';
4 |
5 | import {FlattenedNode} from './shapes/nodeShapes';
6 | import TreeState, {State} from './state/TreeState';
7 |
8 | export default class Tree extends React.Component {
9 | _cache = new CellMeasurerCache({
10 | fixedWidth: true,
11 | minHeight: 20,
12 | });
13 |
14 | getRowCount = () => {
15 | const {nodes} = this.props;
16 |
17 | return nodes instanceof State ? nodes.flattenedTree.length : nodes.length;
18 | };
19 |
20 | getNodeDeepness = (node, index) => {
21 | const {nodes} = this.props;
22 |
23 | if (nodes instanceof State) {
24 | TreeState.getNodeDeepness(nodes, index);
25 | }
26 |
27 | return nodes instanceof State ? TreeState.getNodeDeepness(nodes, index) : node.deepness;
28 | };
29 |
30 | getNode = index => {
31 | const {nodes} = this.props;
32 |
33 | return nodes instanceof State
34 | ? {...TreeState.getNodeAt(nodes, index), deepness: this.getNodeDeepness({}, index)}
35 | : nodes[index];
36 | };
37 |
38 | rowRenderer = ({node, key, measure, style, NodeRenderer, index}) => {
39 | const {nodeMarginLeft} = this.props;
40 |
41 | return (
42 |
55 | );
56 | };
57 |
58 | measureRowRenderer = nodes => ({key, index, style, parent}) => {
59 | const {NodeRenderer} = this.props;
60 | const node = this.getNode(index);
61 |
62 | return (
63 |
64 | {m => this.rowRenderer({...m, index, node, key, style, NodeRenderer})}
65 |
66 | );
67 | };
68 |
69 | render() {
70 | const {nodes, width, scrollToIndex, scrollToAlignment} = this.props;
71 |
72 | return (
73 |
74 | {({height, width: autoWidth}) => (
75 | (this._list = r)}
78 | height={height}
79 | rowCount={this.getRowCount()}
80 | rowHeight={this._cache.rowHeight}
81 | rowRenderer={this.measureRowRenderer(nodes)}
82 | width={width || autoWidth}
83 | scrollToIndex={scrollToIndex}
84 | scrollToAlignment={scrollToAlignment}
85 | />
86 | )}
87 |
88 | );
89 | }
90 | }
91 |
92 | Tree.propTypes = {
93 | nodes: PropTypes.arrayOf(PropTypes.shape(FlattenedNode)).isRequired,
94 | NodeRenderer: PropTypes.func.isRequired,
95 | onChange: PropTypes.func.isRequired,
96 | nodeMarginLeft: PropTypes.number,
97 | width: PropTypes.number,
98 | scrollToIndex: PropTypes.number,
99 | scrollToAlignment: PropTypes.string,
100 | };
101 |
--------------------------------------------------------------------------------
/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/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/__tests__/FilteringContainer.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, cleanup} from 'react-testing-library';
3 | import ReactTestUtils from 'react-dom/test-utils';
4 |
5 | import FilteringContainer from '../FilteringContainer';
6 | import {Nodes} from '../../testData/sampleTree';
7 |
8 | describe('FilteringContainer', () => {
9 | afterEach(cleanup);
10 |
11 | const setup = (extraProps = {}) => {
12 | const child = jest.fn().mockImplementation(() => Child
);
13 |
14 | const props = {nodes: Nodes, debouncer: v => v, ...extraProps};
15 |
16 | const wrapper = render({child} );
17 |
18 | return {
19 | changeFilter: value => {
20 | const input = wrapper.getByPlaceholderText('Search...');
21 |
22 | ReactTestUtils.Simulate.change(input, {
23 | target: {
24 | value,
25 | },
26 | });
27 | },
28 | getInjectedNodes: () => {
29 | return child.mock.calls.slice(-1)[0][0].nodes;
30 | },
31 | wrapper,
32 | child,
33 | props,
34 | };
35 | };
36 |
37 | describe('filtering', () => {
38 | it('should not filter when searchTerm is empty', () => {
39 | const {changeFilter, props, getInjectedNodes} = setup();
40 |
41 | changeFilter('');
42 |
43 | expect(getInjectedNodes()).toEqual(props.nodes);
44 | });
45 |
46 | it('should filter for deepsearch', async () => {
47 | const {changeFilter, getInjectedNodes} = setup();
48 |
49 | changeFilter('2');
50 |
51 | expect(getInjectedNodes()).toMatchSnapshot();
52 | });
53 |
54 | it('should filter when results match more then 1 node', () => {
55 | const {changeFilter, getInjectedNodes} = setup();
56 |
57 | changeFilter('1');
58 |
59 | expect(getInjectedNodes()).toMatchSnapshot();
60 | });
61 |
62 | it('should filter when there are no results', () => {
63 | const {changeFilter, getInjectedNodes} = setup();
64 |
65 | changeFilter('Node');
66 |
67 | expect(getInjectedNodes()).toEqual([]);
68 | });
69 |
70 | it('should ignore boundarie spaces when filtering', () => {
71 | const {changeFilter, getInjectedNodes} = setup();
72 |
73 | changeFilter('1 ');
74 |
75 | expect(getInjectedNodes()).toMatchSnapshot();
76 | });
77 |
78 | it('should ignore casing when filtering', () => {
79 | const {changeFilter, getInjectedNodes} = setup();
80 |
81 | changeFilter('LEAf 3');
82 |
83 | expect(getInjectedNodes()).toMatchSnapshot();
84 | });
85 | });
86 |
87 | describe('groups', () => {
88 | it('when groups do not exist should not render groups related info', () => {
89 | const {wrapper} = setup();
90 |
91 | expect(wrapper.container.querySelector('.group')).toBeNull();
92 | expect(wrapper.container.querySelector('.tree-group')).toBeNull();
93 | });
94 |
95 | describe('when groups exist', () => {
96 | const EXPANDED = 'EXPANDED';
97 |
98 | const setupWithGroups = (extraProps = {}) =>
99 | setup({
100 | groups: {
101 | [EXPANDED]: {
102 | name: 'Expanded',
103 | filter: node => (node.state || {}).expanded,
104 | },
105 | FAVORITES: {
106 | name: 'Favorites',
107 | filter: node => (node.state || {}).favorite,
108 | },
109 | },
110 | selectedGroup: EXPANDED,
111 | onSelectedGroupChange: jest.fn(),
112 | ...extraProps,
113 | });
114 |
115 | it('should render the expected className', () => {
116 | const {wrapper} = setupWithGroups();
117 |
118 | expect(wrapper.container.querySelector('.group')).not.toBeNull();
119 | });
120 |
121 | it('should render the DefaultGroupRenderer when one is not injected as a prop', () => {
122 | const {wrapper} = setupWithGroups();
123 |
124 | expect(wrapper.container.querySelector('.tree-group')).toMatchSnapshot();
125 | });
126 |
127 | it('should render a injected groupRenderer', () => {
128 | const GroupRenderer = props => Group renderer! {JSON.stringify(props)}
;
129 |
130 | const {wrapper} = setupWithGroups({
131 | groupRenderer: GroupRenderer,
132 | });
133 |
134 | expect(wrapper.getByTestId('group-renderer')).toMatchSnapshot();
135 | });
136 |
137 | it('should filter results based on the selected group', () => {
138 | const {getInjectedNodes} = setupWithGroups();
139 |
140 | expect(getInjectedNodes()).toMatchSnapshot();
141 | });
142 |
143 | it('should work when matched with filtering', () => {
144 | const {getInjectedNodes, changeFilter} = setupWithGroups();
145 |
146 | changeFilter('1');
147 |
148 | expect(getInjectedNodes()).toMatchSnapshot();
149 | });
150 | });
151 | });
152 | });
153 |
--------------------------------------------------------------------------------
/src/__tests__/Tree/__snapshots__/render.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Tree rendering should render as expected on mount 1`] = `
4 |
7 |
10 |
14 | Leaf 1
15 |
16 |
17 |
20 |
24 | Leaf 2
25 |
26 |
27 |
30 |
34 | Leaf 3
35 |
36 |
37 |
40 |
44 | Leaf 4
45 |
46 |
47 |
50 |
54 | Leaf 5
55 |
56 |
57 |
60 |
64 | Leaf 6
65 |
66 |
67 |
70 |
74 | Leaf z
75 |
76 |
77 |
78 | `;
79 |
80 | exports[`Tree rendering should render as expected on updates: all-expanded 1`] = `
81 |
84 |
87 |
91 | Leaf 1
92 |
93 |
94 |
97 |
101 | Leaf 2
102 |
103 |
104 |
107 |
111 | Leaf 3
112 |
113 |
114 |
117 |
121 | Leaf 3 Child
122 |
123 |
124 |
127 |
131 | Leaf 4
132 |
133 |
134 |
137 |
141 | Leaf 5
142 |
143 |
144 |
147 |
151 | Leaf 6
152 |
153 |
154 |
157 |
161 | Leaf 7
162 |
163 |
164 |
167 |
171 | Leaf 8
172 |
173 |
174 |
177 |
181 | Leaf 9
182 |
183 |
184 |
187 |
191 | Leaf 10
192 |
193 |
194 |
197 |
201 | Leaf z
202 |
203 |
204 |
205 | `;
206 |
207 | exports[`Tree rendering should render as expected on updates: remove-in-pair-root-pos 1`] = `
208 |
211 |
214 |
218 | Leaf 6
219 |
220 |
221 |
224 |
228 | Leaf 7
229 |
230 |
231 |
234 |
238 | Leaf 8
239 |
240 |
241 |
244 |
248 | Leaf 9
249 |
250 |
251 |
254 |
258 | Leaf 10
259 |
260 |
261 |
262 | `;
263 |
--------------------------------------------------------------------------------
/src/__tests__/Tree/extensions.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, {constants, renderers} from '../..';
7 | const {Expandable} = renderers;
8 |
9 | import {Nodes, EXPANDED_NODE_IN_ROOT, COLLAPSED_NODE_IN_ROOT} from '../../../testData/sampleTree';
10 | import NodeDiff from '../../../tests/NodeDiff';
11 |
12 | const EXPAND_ICON_CN = 'EXPAND';
13 | const COLLAPSE_ICON_CN = 'COLLAPSE';
14 |
15 | // Create a new type, renderer and a function to handle updates for selection
16 | const SELECT = 3;
17 |
18 | const SelectionRender = ({node, children, onChange}) => {
19 | const {state: {selected} = {}} = node;
20 |
21 | return (
22 |
23 |
27 | onChange({
28 | node: {
29 | ...node,
30 | state: {
31 | ...(node.state || {}),
32 | selected: !selected,
33 | },
34 | },
35 | type: SELECT,
36 | })
37 | }
38 | />
39 | {children}
40 |
41 | );
42 | };
43 |
44 | const selectNodes = (nodes, selected) =>
45 | nodes.map(n => ({
46 | ...n,
47 | children: n.children ? selectNodes(n.children, selected) : [],
48 | state: {
49 | ...n.state,
50 | selected,
51 | },
52 | }));
53 |
54 | const nodeSelectionHandler = (nodes, updatedNode) =>
55 | nodes.map(node => {
56 | if (node.id === updatedNode.id) {
57 | return {
58 | ...updatedNode,
59 | children: node.children ? selectNodes(node.children, updatedNode.state.selected) : [],
60 | };
61 | }
62 |
63 | if (node.children) {
64 | return {...node, children: nodeSelectionHandler(node.children, updatedNode)};
65 | }
66 |
67 | return node;
68 | });
69 |
70 | const toggleNodeVisibility = (nodes, expanded) =>
71 | nodes.map(n => ({
72 | ...n,
73 | children: n.children ? toggleNodeVisibility(n.children, expanded) : [],
74 | state: {
75 | ...n.state,
76 | expanded,
77 | },
78 | }));
79 |
80 | const toggleAllChildrenVisibility = (nodes, updatedNode) =>
81 | nodes.map(node => {
82 | if (node.id === updatedNode.id) {
83 | const children = node.children ? toggleNodeVisibility(node.children, updatedNode.state.expanded) : [];
84 |
85 | return {
86 | ...updatedNode,
87 | children,
88 | };
89 | }
90 |
91 | return node;
92 | });
93 |
94 | class Example extends React.Component {
95 | state = {
96 | nodes: Nodes,
97 | };
98 |
99 | handleChange = nodes => {
100 | this.setState({nodes});
101 | };
102 |
103 | render() {
104 | const {extensions} = this.props;
105 | return (
106 |
107 |
108 | {({style, node, ...rest}) => (
109 |
110 |
118 |
119 | {node.name}
120 |
121 |
122 |
123 | )}
124 |
125 |
126 | );
127 | }
128 | }
129 |
130 | describe('Tree extensions', () => {
131 | afterEach(cleanup);
132 |
133 | describe('on mount', () => {
134 | test('should be able to plug in a new action', () => {
135 | // Render a tree that when selected selects all children.
136 | const {container, getByTestId} = render(
137 | ,
144 | );
145 |
146 | expect(container.querySelectorAll('input:checked').length).toBe(0);
147 |
148 | const {id} = EXPANDED_NODE_IN_ROOT;
149 |
150 | let targetExpandedNode = getByTestId(`${id}`);
151 |
152 | fireEvent.click(targetExpandedNode.querySelector('input'));
153 |
154 | // Number of visible children for the clicked node
155 | expect(container.querySelectorAll('input:checked').length).toBe(5);
156 | });
157 |
158 | test('should be able to override an existing action', () => {
159 | // Render a tree that when override the update function, expanding all children when a node is expanded
160 | const {container, getByTestId} = render(
161 | ,
168 | );
169 |
170 | const diff = new NodeDiff(container);
171 |
172 | const {id} = COLLAPSED_NODE_IN_ROOT;
173 | const targetExpandedNode = getByTestId(`${id}`);
174 |
175 | fireEvent.click(targetExpandedNode.querySelector(`.${COLLAPSE_ICON_CN}`));
176 |
177 | expect(diff.run(container)).toMatchInlineSnapshot(`
178 | Object {
179 | "changed": Array [
180 | "1",
181 | ],
182 | "mounted": Array [
183 | "6",
184 | "7",
185 | "8",
186 | "9",
187 | ],
188 | "unmounted": Array [],
189 | }
190 | `);
191 | });
192 | });
193 |
194 | describe('on update', () => {
195 | test('should be able to plug in a new action', () => {
196 | // Do an initial render without extensions.
197 | const {rerender, container, getByTestId} = render( );
198 |
199 | // Re-Render a tree that when selected selects all children.
200 | rerender(
201 | ,
208 | );
209 |
210 | expect(container.querySelectorAll('input:checked').length).toBe(0);
211 |
212 | const {id} = EXPANDED_NODE_IN_ROOT;
213 |
214 | let targetExpandedNode = getByTestId(`${id}`);
215 |
216 | fireEvent.click(targetExpandedNode.querySelector('input'));
217 |
218 | // Number of visible children for the clicked node
219 | expect(container.querySelectorAll('input:checked').length).toBe(5);
220 | });
221 |
222 | test('should be able to override an existing action', () => {
223 | // Do an initial render without extensions.
224 | const {rerender, container, getByTestId} = render( );
225 |
226 | // Render a tree that when override the update function, expanding all children when a node is expanded
227 | rerender(
228 | ,
235 | );
236 |
237 | const diff = new NodeDiff(container);
238 |
239 | const {id} = COLLAPSED_NODE_IN_ROOT;
240 | const targetExpandedNode = getByTestId(`${id}`);
241 |
242 | fireEvent.click(targetExpandedNode.querySelector(`.${COLLAPSE_ICON_CN}`));
243 |
244 | expect(diff.run(container)).toMatchInlineSnapshot(`
245 | Object {
246 | "changed": Array [
247 | "1",
248 | ],
249 | "mounted": Array [
250 | "6",
251 | "7",
252 | "8",
253 | "9",
254 | ],
255 | "unmounted": Array [],
256 | }
257 | `);
258 | });
259 | });
260 | });
261 |
--------------------------------------------------------------------------------
/src/__tests__/Tree/filtering.test.js:
--------------------------------------------------------------------------------
1 | jest.mock('react-virtualized');
2 |
3 | import React from 'react';
4 | import {render, cleanup, fireEvent} from 'react-testing-library';
5 | import ReactTestUtils from 'react-dom/test-utils';
6 |
7 | import Tree, {renderers} from '../..';
8 | import FilteringContainer from '../../FilteringContainer';
9 |
10 | const {Expandable} = renderers;
11 |
12 | class Example extends React.Component {
13 | state = {
14 | nodes: [
15 | {
16 | id: 0,
17 | name: 'Top Facebook Eng Tags',
18 | state: {
19 | expanded: true,
20 | },
21 | children: [
22 | {
23 | id: 1,
24 | name: 'react',
25 | state: {
26 | expanded: true,
27 | tags: ['javascript', 'open-source'],
28 | },
29 | },
30 | {
31 | id: 2,
32 | name: 'graphql',
33 | state: {
34 | expanded: true,
35 | tags: ['open-source', 'api'],
36 | },
37 | },
38 | {
39 | id: 3,
40 | name: 'jest',
41 | state: {
42 | expanded: true,
43 | tags: ['javascript', 'open-source'],
44 | },
45 | },
46 | ],
47 | },
48 | {
49 | id: 4,
50 | name: 'Top Google Eng Tags',
51 | state: {
52 | expanded: true,
53 | },
54 | children: [
55 | {
56 | id: 5,
57 | name: 'chrome',
58 | state: {
59 | expanded: true,
60 | tags: ['browser'],
61 | },
62 | },
63 | {
64 | id: 21,
65 | name: 'chromium',
66 | state: {
67 | expanded: true,
68 | tags: ['browser', 'open-source'],
69 | },
70 | },
71 | {
72 | id: 20,
73 | name: 'chromium os',
74 | state: {
75 | expanded: true,
76 | tags: ['browser', 'open-source'],
77 | },
78 | },
79 | {
80 | id: 6,
81 | name: 'angular',
82 | state: {
83 | expanded: true,
84 | tags: ['javascript', 'open-source'],
85 | },
86 | },
87 | {
88 | id: 7,
89 | name: 'google-cloud',
90 | state: {
91 | expanded: true,
92 | tags: ['cloud'],
93 | },
94 | },
95 | ],
96 | },
97 | {
98 | id: 8,
99 | name: 'Top Microsoft Eng Tags',
100 | state: {
101 | expanded: true,
102 | },
103 | children: [
104 | {
105 | id: 9,
106 | name: 'typescript',
107 | state: {
108 | expanded: true,
109 | tags: ['javascript', 'open-source'],
110 | },
111 | },
112 | {
113 | id: 10,
114 | name: 'vscode',
115 | state: {
116 | expanded: true,
117 | tags: ['code-editor'],
118 | },
119 | },
120 | {
121 | id: 11,
122 | name: 'azure',
123 | state: {
124 | expanded: true,
125 | tags: ['cloud'],
126 | },
127 | },
128 | ],
129 | },
130 | {
131 | id: 12,
132 | name: 'Top Global Eng Tags',
133 | state: {
134 | expanded: true,
135 | },
136 | children: [
137 | {
138 | id: 13,
139 | name: 'clouds',
140 | state: {
141 | expanded: false,
142 | },
143 | children: [
144 | {
145 | id: 14,
146 | name: 'google-cloud',
147 | tags: ['cloud'],
148 | },
149 | {
150 | id: 15,
151 | name: 'azure',
152 | tags: ['cloud'],
153 | },
154 | ],
155 | },
156 | {
157 | id: 16,
158 | name: 'front-end',
159 | state: {
160 | expanded: true,
161 | },
162 | children: [
163 | {
164 | id: 17,
165 | name: 'react',
166 | tags: ['javascript', 'open-source'],
167 | },
168 | {
169 | id: 18,
170 | name: 'vue',
171 | tags: ['javascript', 'open-source'],
172 | },
173 | {
174 | id: 19,
175 | name: 'angular',
176 | tags: ['javascript', 'open-source'],
177 | },
178 | ],
179 | },
180 | ],
181 | },
182 | ],
183 | selectedGroup: undefined,
184 | };
185 |
186 | handleChange = nodes => {
187 | this.setState({nodes});
188 | };
189 |
190 | handleSelectedGroupChange = selectedGroup => {
191 | this.setState({selectedGroup});
192 | };
193 |
194 | render() {
195 | return (
196 | v}
199 | selectedGroup={this.state.selectedGroup}
200 | onSelectedGroupChange={this.handleSelectedGroupChange}
201 | {...this.props}
202 | >
203 | {({nodes}) => (
204 |
205 | {({style, node, ...rest}) => (
206 |
207 |
208 | {node.name}
209 |
210 |
211 | )}
212 |
213 | )}
214 |
215 | );
216 | }
217 | }
218 |
219 | describe('Tree with filtering container', () => {
220 | afterEach(cleanup);
221 |
222 | const getRenderedNodesNames = container => {
223 | const nodes = container.querySelectorAll('[data-type="node"]');
224 |
225 | const names = [];
226 | nodes.forEach(n => {
227 | names.push(n.textContent);
228 | });
229 |
230 | return names;
231 | };
232 |
233 | const setup = (props = {}) => {
234 | const wrapper = render( );
235 |
236 | return {
237 | search: value => {
238 | const input = wrapper.getByPlaceholderText('Search...');
239 |
240 | ReactTestUtils.Simulate.change(input, {
241 | target: {
242 | value,
243 | },
244 | });
245 | },
246 | wrapper,
247 | };
248 | };
249 |
250 | describe('without groups', () => {
251 | test('should render an empty tree when search does not match any node', () => {
252 | const {wrapper, search} = setup();
253 |
254 | search('Random');
255 |
256 | expect(wrapper.container.querySelector('[data-type="node"]')).toBeNull();
257 | });
258 |
259 | test('should render as expected when trying to search for a node in the root', () => {
260 | const {wrapper, search} = setup();
261 |
262 | search('Top Microsoft');
263 |
264 | expect(getRenderedNodesNames(wrapper.container)).toMatchInlineSnapshot(`
265 | Array [
266 | "Top Microsoft Eng Tags",
267 | ]
268 | `);
269 | });
270 |
271 | test('should render as expected when trying to search for nodes expanded deep within', () => {
272 | const {wrapper, search} = setup();
273 | search('react');
274 |
275 | expect(getRenderedNodesNames(wrapper.container)).toMatchInlineSnapshot(`
276 | Array [
277 | "Top Facebook Eng Tags",
278 | "react",
279 | "Top Global Eng Tags",
280 | "front-end",
281 | "react",
282 | ]
283 | `);
284 | });
285 |
286 | test('should render as expected when trying to search for nodes collapsed deep within', () => {
287 | const {wrapper, search} = setup();
288 |
289 | search('google-cloud');
290 |
291 | expect(getRenderedNodesNames(wrapper.container)).toMatchInlineSnapshot(`
292 | Array [
293 | "Top Google Eng Tags",
294 | "google-cloud",
295 | "Top Global Eng Tags",
296 | "clouds",
297 | ]
298 | `);
299 | });
300 | });
301 |
302 | describe('with groups', () => {
303 | const setupWithGroups = () => {
304 | const rendered = setup({
305 | groups: {
306 | JS: {
307 | name: 'JS',
308 | filter: ({state = {}}) => {
309 | const {tags = []} = state;
310 |
311 | return tags.includes('javascript');
312 | },
313 | },
314 | OPEN_SOURCE: {
315 | name: 'OPEN_SOURCE',
316 | filter: ({state = {}}) => {
317 | const {tags = []} = state;
318 |
319 | return tags.includes('open-source');
320 | },
321 | },
322 | },
323 | });
324 |
325 | return {
326 | ...rendered,
327 | selectGroup: value => {
328 | const {container} = rendered.wrapper;
329 |
330 | const select = container.querySelector('select');
331 | select.value = value;
332 |
333 | fireEvent.change(select);
334 | },
335 | };
336 | };
337 |
338 | test('should render and filter as expected when selecting a group', () => {
339 | const {wrapper, selectGroup} = setupWithGroups();
340 |
341 | selectGroup('JS');
342 |
343 | expect(getRenderedNodesNames(wrapper.container)).toMatchInlineSnapshot(`
344 | Array [
345 | "Top Facebook Eng Tags",
346 | "react",
347 | "jest",
348 | "Top Google Eng Tags",
349 | "angular",
350 | "Top Microsoft Eng Tags",
351 | "typescript",
352 | ]
353 | `);
354 | });
355 |
356 | test('should render an empty tree when search does not match any node in the group', () => {
357 | const {wrapper, search, selectGroup} = setupWithGroups();
358 |
359 | // Before selecting the group the search should return
360 | search('azure');
361 | expect(wrapper.container.querySelector('[data-type="node"]')).not.toBeNull();
362 |
363 | selectGroup('JS');
364 | search('azure');
365 |
366 | expect(wrapper.container.querySelector('[data-type="node"]')).toBeNull();
367 | });
368 |
369 | test('should render and filter a tree correctly using the group and search term', () => {
370 | const {wrapper, search, selectGroup} = setupWithGroups();
371 |
372 | selectGroup('OPEN_SOURCE');
373 | search('chrom');
374 |
375 | expect(getRenderedNodesNames(wrapper.container)).toMatchInlineSnapshot(`
376 | Array [
377 | "Top Google Eng Tags",
378 | "chromium",
379 | "chromium os",
380 | ]
381 | `);
382 | });
383 | });
384 | });
385 |
--------------------------------------------------------------------------------
/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 |
47 | this.setState({
48 | nodes: this.expandAllNodes(this.state.nodes),
49 | })
50 | }
51 | />
52 |
53 |
54 |
55 | {({style, node, ...rest}) => (
56 |
57 |
58 | {node.name}
59 |
60 |
61 | )}
62 |
63 |
64 |
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/__tests__/__snapshots__/FilteringContainer.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`FilteringContainer filtering should filter for deepsearch 1`] = `
4 | Array [
5 | Object {
6 | "children": Array [
7 | Object {
8 | "children": Array [],
9 | "id": 2,
10 | "name": "Leaf 2",
11 | "state": Object {
12 | "deletable": true,
13 | "expanded": true,
14 | },
15 | },
16 | ],
17 | "id": 0,
18 | "name": "Leaf 1",
19 | "state": Object {
20 | "expanded": true,
21 | },
22 | },
23 | ]
24 | `;
25 |
26 | exports[`FilteringContainer filtering should filter when results match more then 1 node 1`] = `
27 | Array [
28 | Object {
29 | "children": Array [],
30 | "id": 0,
31 | "name": "Leaf 1",
32 | "state": Object {
33 | "expanded": true,
34 | },
35 | },
36 | Object {
37 | "children": Array [
38 | Object {
39 | "children": Array [],
40 | "id": 9,
41 | "name": "Leaf 10",
42 | },
43 | ],
44 | "id": 1,
45 | "name": "Leaf 6",
46 | "state": Object {
47 | "deletable": true,
48 | "expanded": false,
49 | },
50 | },
51 | ]
52 | `;
53 |
54 | exports[`FilteringContainer filtering should ignore boundarie spaces when filtering 1`] = `
55 | Array [
56 | Object {
57 | "children": Array [],
58 | "id": 0,
59 | "name": "Leaf 1",
60 | "state": Object {
61 | "expanded": true,
62 | },
63 | },
64 | Object {
65 | "children": Array [
66 | Object {
67 | "children": Array [],
68 | "id": 9,
69 | "name": "Leaf 10",
70 | },
71 | ],
72 | "id": 1,
73 | "name": "Leaf 6",
74 | "state": Object {
75 | "deletable": true,
76 | "expanded": false,
77 | },
78 | },
79 | ]
80 | `;
81 |
82 | exports[`FilteringContainer filtering should ignore casing when filtering 1`] = `
83 | Array [
84 | Object {
85 | "children": Array [
86 | Object {
87 | "children": Array [
88 | Object {
89 | "children": Array [
90 | Object {
91 | "children": Array [],
92 | "id": "c-3",
93 | "name": "Leaf 3 Child",
94 | "state": Object {},
95 | },
96 | ],
97 | "id": 3,
98 | "name": "Leaf 3",
99 | "state": Object {
100 | "deletable": true,
101 | "expanded": false,
102 | "favorite": true,
103 | },
104 | },
105 | ],
106 | "id": 2,
107 | "name": "Leaf 2",
108 | "state": Object {
109 | "deletable": true,
110 | "expanded": true,
111 | },
112 | },
113 | ],
114 | "id": 0,
115 | "name": "Leaf 1",
116 | "state": Object {
117 | "expanded": true,
118 | },
119 | },
120 | ]
121 | `;
122 |
123 | exports[`FilteringContainer groups when groups exist should filter results based on the selected group 1`] = `
124 | Array [
125 | Object {
126 | "children": Array [
127 | Object {
128 | "children": Array [],
129 | "id": 2,
130 | "name": "Leaf 2",
131 | "state": Object {
132 | "deletable": true,
133 | "expanded": true,
134 | },
135 | },
136 | ],
137 | "id": 0,
138 | "name": "Leaf 1",
139 | "state": Object {
140 | "expanded": true,
141 | },
142 | },
143 | ]
144 | `;
145 |
146 | exports[`FilteringContainer groups when groups exist should render a injected groupRenderer 1`] = `
147 |
150 | Group renderer!
151 | {"groups":{"EXPANDED":{"name":"Expanded"},"FAVORITES":{"name":"Favorites"}},"selectedGroup":"EXPANDED"}
152 |
153 | `;
154 |
155 | exports[`FilteringContainer groups when groups exist should render the DefaultGroupRenderer when one is not injected as a prop 1`] = `
156 |
159 |
162 | Expanded
163 |
164 |
167 | Favorites
168 |
169 |
170 | `;
171 |
172 | exports[`FilteringContainer groups when groups exist should work when matched with filtering 1`] = `
173 | Array [
174 | Object {
175 | "children": Array [],
176 | "id": 0,
177 | "name": "Leaf 1",
178 | "state": Object {
179 | "expanded": true,
180 | },
181 | },
182 | ]
183 | `;
184 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/contants.js:
--------------------------------------------------------------------------------
1 | export const UPDATE_TYPE = {
2 | ADD: 0,
3 | DELETE: 1,
4 | UPDATE: 2,
5 | };
6 |
--------------------------------------------------------------------------------
/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/filtering/DefaultGroupRenderer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const DefaultGroupRenderer = ({onChange, groups, selectedGroup}) => {
4 | return (
5 | {
8 | onChange(value);
9 | }}
10 | value={selectedGroup}
11 | >
12 | {Object.keys(groups).map(g => (
13 |
14 | {groups[g].name}
15 |
16 | ))}
17 |
18 | );
19 | };
20 |
21 | export default DefaultGroupRenderer;
22 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/src/renderers/__tests__/Expandable.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, cleanup, fireEvent} from 'react-testing-library';
3 |
4 | import Expandable from '../Expandable';
5 | import {KEY_CODES} from '../../eventWrappers';
6 | import {updateNode} from '../../selectors/nodes';
7 |
8 | describe('renderers Expandable', () => {
9 | afterEach(cleanup);
10 |
11 | const setup = (state = {}, 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 contains children', () => {
34 | describe('when expanded', () => {
35 | it('should render a with the supplied className when expanded', () => {
36 | const {container, props} = setup(
37 | {expanded: true},
38 | {
39 | iconsClassNameMap: {
40 | expanded: 'expanded',
41 | collapsed: 'colpased',
42 | },
43 | },
44 | );
45 |
46 | expect(container.querySelector(`.${props.iconsClassNameMap.expanded}`)).not.toBeNull();
47 | expect(container.querySelector(`.mi.mi-keyboard-arrow-down`)).toBeNull();
48 | });
49 |
50 | it('should render a with the default className when expanded and className map is not supplied', () => {
51 | const {container} = setup({expanded: true});
52 |
53 | expect(container.querySelector(`.mi.mi-keyboard-arrow-down`)).not.toBeNull();
54 | });
55 |
56 | it('clicking should call onChange with the correct params', () => {
57 | const {container, props} = setup({expanded: true});
58 |
59 | fireEvent.click(container.querySelector('i'));
60 |
61 | expect(props.onChange).toHaveBeenCalledWith({...updateNode(props.node, {expanded: false}), index: props.index});
62 | });
63 |
64 | it('pressing enter should call onChange with the correct params', () => {
65 | const {container, props} = setup({expanded: true});
66 |
67 | fireEvent.keyDown(container.querySelector('i'), {keyCode: KEY_CODES.Enter});
68 |
69 | expect(props.onChange).toHaveBeenCalledWith({...updateNode(props.node, {expanded: false}), index: props.index});
70 | });
71 |
72 | it('double clicking in the parent node should call onChange with the correct params', () => {
73 | const {props, container} = setup({expanded: true});
74 |
75 | fireEvent.doubleClick(container.querySelector('i'));
76 |
77 | expect(props.onChange).toHaveBeenCalledWith({...updateNode(props.node, {expanded: false}), index: props.index});
78 | });
79 | });
80 |
81 | describe('when collapsed', () => {
82 | it('should render a with the supplied className when expanded', () => {
83 | const {container, props} = setup(
84 | {expanded: false},
85 | {
86 | iconsClassNameMap: {
87 | expanded: 'expanded',
88 | collapsed: 'colpased',
89 | },
90 | },
91 | );
92 |
93 | expect(container.querySelector(`.${props.iconsClassNameMap.collapsed}`)).not.toBeNull();
94 | expect(container.querySelector(`.mi.mi-keyboard-arrow-right`)).toBeNull();
95 | });
96 |
97 | it('should render a with the supplied default className when map is not supplied', () => {
98 | const {container} = setup({expanded: false});
99 |
100 | expect(container.querySelector(`.mi.mi-keyboard-arrow-right`)).not.toBeNull();
101 | });
102 |
103 | it('clicking should call onChange with the correct params', () => {
104 | const {container, props} = setup({expanded: false});
105 |
106 | fireEvent.click(container.querySelector('i'));
107 |
108 | expect(props.onChange).toHaveBeenCalledWith({...updateNode(props.node, {expanded: true}), index: props.index});
109 | });
110 |
111 | it('pressing enter should call onChange with the correct params', () => {
112 | const {container, props} = setup({expanded: false});
113 |
114 | fireEvent.keyDown(container.querySelector('i'), {keyCode: KEY_CODES.Enter});
115 |
116 | expect(props.onChange).toHaveBeenCalledWith({...updateNode(props.node, {expanded: true}), index: props.index});
117 | });
118 |
119 | it('double clicking in the parent node should call onChange with the correct params', () => {
120 | const {props, container} = setup({expanded: false});
121 |
122 | fireEvent.doubleClick(container.querySelector('i'));
123 |
124 | expect(props.onChange).toHaveBeenCalledWith({...updateNode(props.node, {expanded: true}), index: props.index});
125 | });
126 | });
127 | });
128 | });
129 |
--------------------------------------------------------------------------------
/src/renderers/__tests__/Favorite.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, cleanup, fireEvent} from 'react-testing-library';
3 |
4 | import Favorite from '../Favorite';
5 | import {KEY_CODES} from '../../eventWrappers';
6 | import {updateNode} from '../../selectors/nodes';
7 |
8 | describe('renderers Favorite', () => {
9 | afterEach(cleanup);
10 |
11 | const setup = (state = {}, 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 favorite', () => {
34 | it('should render an icon with the supplied className', () => {
35 | const {container, props} = setup(
36 | {favorite: true},
37 | {
38 | iconsClassNameMap: {
39 | favorite: 'fav',
40 | notFavorite: 'non-fav',
41 | },
42 | },
43 | );
44 |
45 | expect(container.querySelector(`.${props.iconsClassNameMap.favorite}`)).not.toBeNull();
46 | expect(container.querySelector(`.mi.mi-star`)).toBeNull();
47 | });
48 |
49 | it('should render an icon with the default className when a map is not provided', () => {
50 | const {container} = setup({favorite: true});
51 |
52 | expect(container.querySelector(`.mi.mi-star`)).not.toBeNull();
53 | });
54 |
55 | it('clicking should call onChange with the correct params', () => {
56 | const {container, props} = setup({favorite: true});
57 |
58 | fireEvent.click(container.querySelector('i'));
59 |
60 | expect(props.onChange).toHaveBeenCalledWith({...updateNode(props.node, {favorite: false}), index: props.index});
61 | });
62 |
63 | it('pressing enter should call onChange with the correct params', () => {
64 | const {container, props} = setup({favorite: true});
65 |
66 | fireEvent.keyDown(container.querySelector('i'), {keyCode: KEY_CODES.Enter});
67 |
68 | expect(props.onChange).toHaveBeenCalledWith({...updateNode(props.node, {favorite: false}), index: props.index});
69 | });
70 | });
71 |
72 | describe('when not favorite', () => {
73 | it('should render a with the supplied className', () => {
74 | const {container, props} = setup(
75 | {favorite: false},
76 | {
77 | iconsClassNameMap: {
78 | favorite: 'fav',
79 | notFavorite: 'non-fav',
80 | },
81 | },
82 | );
83 |
84 | expect(container.querySelector(`.${props.iconsClassNameMap.notFavorite}`)).not.toBeNull();
85 | expect(container.querySelector(`.mi.mi-star-border`)).toBeNull();
86 | });
87 |
88 | it('should render an icon with the default className when a map is not provided', () => {
89 | const {container} = setup({favorite: false});
90 |
91 | expect(container.querySelector(`.mi.mi-star-border`)).not.toBeNull();
92 | });
93 |
94 | it('clicking should call onChange with the correct params', () => {
95 | const {container, props} = setup({favorite: false});
96 |
97 | fireEvent.click(container.querySelector('i'));
98 |
99 | expect(props.onChange).toHaveBeenCalledWith({...updateNode(props.node, {favorite: true}), index: props.index});
100 | });
101 |
102 | it('pressing enter should call onChange with the correct params', () => {
103 | const {container, props} = setup({favorite: false});
104 |
105 | fireEvent.keyDown(container.querySelector('i'), {keyCode: KEY_CODES.Enter});
106 |
107 | expect(props.onChange).toHaveBeenCalledWith({...updateNode(props.node, {favorite: true}), index: props.index});
108 | });
109 | });
110 | });
111 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/selectors/__tests__/__snapshots__/getFlattenedTree.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`getFlattenedTree should match snapshot 1`] = `
4 | Array [
5 | Object {
6 | "children": Array [
7 | Object {
8 | "children": Array [
9 | Object {
10 | "children": Array [
11 | Object {
12 | "id": "c-3",
13 | "name": "Leaf 3 Child",
14 | "state": Object {},
15 | },
16 | ],
17 | "id": 3,
18 | "name": "Leaf 3",
19 | "state": Object {
20 | "deletable": true,
21 | "expanded": false,
22 | "favorite": true,
23 | },
24 | },
25 | Object {
26 | "id": 4,
27 | "name": "Leaf 4",
28 | },
29 | ],
30 | "id": 2,
31 | "name": "Leaf 2",
32 | "state": Object {
33 | "deletable": true,
34 | "expanded": true,
35 | },
36 | },
37 | Object {
38 | "id": 5,
39 | "name": "Leaf 5",
40 | },
41 | ],
42 | "deepness": 0,
43 | "id": 0,
44 | "name": "Leaf 1",
45 | "parents": Array [],
46 | "state": Object {
47 | "expanded": true,
48 | },
49 | },
50 | Object {
51 | "children": Array [
52 | Object {
53 | "children": Array [
54 | Object {
55 | "id": "c-3",
56 | "name": "Leaf 3 Child",
57 | "state": Object {},
58 | },
59 | ],
60 | "id": 3,
61 | "name": "Leaf 3",
62 | "state": Object {
63 | "deletable": true,
64 | "expanded": false,
65 | "favorite": true,
66 | },
67 | },
68 | Object {
69 | "id": 4,
70 | "name": "Leaf 4",
71 | },
72 | ],
73 | "deepness": 1,
74 | "id": 2,
75 | "name": "Leaf 2",
76 | "parents": Array [
77 | 0,
78 | ],
79 | "state": Object {
80 | "deletable": true,
81 | "expanded": true,
82 | },
83 | },
84 | Object {
85 | "children": Array [
86 | Object {
87 | "id": "c-3",
88 | "name": "Leaf 3 Child",
89 | "state": Object {},
90 | },
91 | ],
92 | "deepness": 2,
93 | "id": 3,
94 | "name": "Leaf 3",
95 | "parents": Array [
96 | 0,
97 | 2,
98 | ],
99 | "state": Object {
100 | "deletable": true,
101 | "expanded": false,
102 | "favorite": true,
103 | },
104 | },
105 | Object {
106 | "deepness": 2,
107 | "id": 4,
108 | "name": "Leaf 4",
109 | "parents": Array [
110 | 0,
111 | 2,
112 | ],
113 | },
114 | Object {
115 | "deepness": 1,
116 | "id": 5,
117 | "name": "Leaf 5",
118 | "parents": Array [
119 | 0,
120 | ],
121 | },
122 | Object {
123 | "children": Array [
124 | Object {
125 | "children": Array [
126 | Object {
127 | "id": 7,
128 | "name": "Leaf 8",
129 | },
130 | Object {
131 | "id": 8,
132 | "name": "Leaf 9",
133 | },
134 | ],
135 | "id": 6,
136 | "name": "Leaf 7",
137 | "state": Object {
138 | "expanded": false,
139 | },
140 | },
141 | Object {
142 | "id": 9,
143 | "name": "Leaf 10",
144 | },
145 | ],
146 | "deepness": 0,
147 | "id": 1,
148 | "name": "Leaf 6",
149 | "parents": Array [],
150 | "state": Object {
151 | "deletable": true,
152 | "expanded": false,
153 | },
154 | },
155 | Object {
156 | "deepness": 0,
157 | "id": "z",
158 | "name": "Leaf z",
159 | "parents": Array [],
160 | "state": Object {
161 | "deletable": true,
162 | "favorite": true,
163 | },
164 | },
165 | ]
166 | `;
167 |
168 | exports[`getFlattenedTreePaths, should match snapshot 1`] = `
169 | Array [
170 | Array [
171 | 0,
172 | ],
173 | Array [
174 | 0,
175 | 2,
176 | ],
177 | Array [
178 | 0,
179 | 2,
180 | 3,
181 | ],
182 | Array [
183 | 0,
184 | 2,
185 | 4,
186 | ],
187 | Array [
188 | 0,
189 | 5,
190 | ],
191 | Array [
192 | 1,
193 | ],
194 | Array [
195 | "z",
196 | ],
197 | ]
198 | `;
199 |
--------------------------------------------------------------------------------
/src/selectors/__tests__/__snapshots__/nodes.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`selectors -> nodes -> Tree actions deleteNodeFromTree when a node is deleted, should delete that node and all children nodes 1`] = `
4 | Array [
5 | Object {
6 | "children": Array [
7 | Object {
8 | "id": 5,
9 | "name": "Leaf 5",
10 | },
11 | ],
12 | "id": 0,
13 | "name": "Leaf 1",
14 | "state": Object {
15 | "expanded": true,
16 | },
17 | },
18 | Object {
19 | "children": Array [
20 | Object {
21 | "children": Array [
22 | Object {
23 | "id": 7,
24 | "name": "Leaf 8",
25 | },
26 | Object {
27 | "id": 8,
28 | "name": "Leaf 9",
29 | },
30 | ],
31 | "id": 6,
32 | "name": "Leaf 7",
33 | "state": Object {
34 | "expanded": false,
35 | },
36 | },
37 | Object {
38 | "id": 9,
39 | "name": "Leaf 10",
40 | },
41 | ],
42 | "id": 1,
43 | "name": "Leaf 6",
44 | "state": Object {
45 | "deletable": true,
46 | "expanded": false,
47 | },
48 | },
49 | Object {
50 | "id": "z",
51 | "name": "Leaf z",
52 | "state": Object {
53 | "deletable": true,
54 | "favorite": true,
55 | },
56 | },
57 | ]
58 | `;
59 |
60 | exports[`selectors -> nodes -> Tree actions deleteNodeFromTree when root node is deleted, should delete that node and all children nodes 1`] = `
61 | Array [
62 | Object {
63 | "children": Array [
64 | Object {
65 | "children": Array [
66 | Object {
67 | "id": 7,
68 | "name": "Leaf 8",
69 | },
70 | Object {
71 | "id": 8,
72 | "name": "Leaf 9",
73 | },
74 | ],
75 | "id": 6,
76 | "name": "Leaf 7",
77 | "state": Object {
78 | "expanded": false,
79 | },
80 | },
81 | Object {
82 | "id": 9,
83 | "name": "Leaf 10",
84 | },
85 | ],
86 | "id": 1,
87 | "name": "Leaf 6",
88 | "state": Object {
89 | "deletable": true,
90 | "expanded": false,
91 | },
92 | },
93 | Object {
94 | "id": "z",
95 | "name": "Leaf z",
96 | "state": Object {
97 | "deletable": true,
98 | "favorite": true,
99 | },
100 | },
101 | ]
102 | `;
103 |
104 | exports[`selectors -> nodes -> Tree actions replaceNodeFromTree should replace a node in the tree without mutations 1`] = `
105 | Array [
106 | Object {
107 | "children": Array [
108 | Object {
109 | "children": Array [
110 | Object {
111 | "children": Array [
112 | Object {
113 | "id": "c-3",
114 | "name": "Leaf 3 Child",
115 | "state": Object {},
116 | },
117 | ],
118 | "id": 3,
119 | "name": "Leaf 3",
120 | "state": Object {
121 | "deletable": true,
122 | "expanded": false,
123 | "favorite": true,
124 | },
125 | },
126 | Object {
127 | "id": 4,
128 | "name": "Leaf 4",
129 | },
130 | ],
131 | "id": 2,
132 | "name": "Leaf 2",
133 | "state": Object {
134 | "deletable": true,
135 | "expanded": true,
136 | },
137 | },
138 | Object {
139 | "id": 5,
140 | "name": "Leaf 5",
141 | },
142 | ],
143 | "id": 0,
144 | "name": "Leaf 1",
145 | "state": Object {
146 | "expanded": false,
147 | "favorite": true,
148 | },
149 | },
150 | Object {
151 | "children": Array [
152 | Object {
153 | "children": Array [
154 | Object {
155 | "id": 7,
156 | "name": "Leaf 8",
157 | },
158 | Object {
159 | "id": 8,
160 | "name": "Leaf 9",
161 | },
162 | ],
163 | "id": 6,
164 | "name": "Leaf 7",
165 | "state": Object {
166 | "expanded": false,
167 | },
168 | },
169 | Object {
170 | "id": 9,
171 | "name": "Leaf 10",
172 | },
173 | ],
174 | "id": 1,
175 | "name": "Leaf 6",
176 | "state": Object {
177 | "deletable": true,
178 | "expanded": false,
179 | },
180 | },
181 | Object {
182 | "id": "z",
183 | "name": "Leaf z",
184 | "state": Object {
185 | "deletable": true,
186 | "favorite": true,
187 | },
188 | },
189 | ]
190 | `;
191 |
192 | exports[`selectors -> nodes -> getNodeRenderOptions should extract state from nodes correctly when there are children 1`] = `
193 | Object {
194 | "hasChildren": true,
195 | "isDeletable": true,
196 | "isExpanded": false,
197 | "isFavorite": false,
198 | }
199 | `;
200 |
201 | exports[`selectors -> nodes -> getNodeRenderOptions should extract state from nodes correctly when there are no children 1`] = `
202 | Object {
203 | "hasChildren": false,
204 | "isDeletable": false,
205 | "isExpanded": true,
206 | "isFavorite": true,
207 | }
208 | `;
209 |
210 | exports[`selectors -> nodes -> getNodeRenderOptions should extract state from nodes correctly when there is no state 1`] = `
211 | Object {
212 | "hasChildren": true,
213 | "isDeletable": false,
214 | "isExpanded": false,
215 | "isFavorite": false,
216 | }
217 | `;
218 |
219 | exports[`selectors -> nodes -> single node actions addNode should create the expected object 1`] = `
220 | Object {
221 | "node": Object {
222 | "children": Array [
223 | Object {
224 | "children": Array [
225 | Object {
226 | "children": Array [
227 | Object {
228 | "id": "c-3",
229 | "name": "Leaf 3 Child",
230 | "state": Object {},
231 | },
232 | ],
233 | "id": 3,
234 | "name": "Leaf 3",
235 | "state": Object {
236 | "deletable": true,
237 | "expanded": false,
238 | "favorite": true,
239 | },
240 | },
241 | Object {
242 | "id": 4,
243 | "name": "Leaf 4",
244 | },
245 | ],
246 | "id": 2,
247 | "name": "Leaf 2",
248 | "state": Object {
249 | "deletable": true,
250 | "expanded": true,
251 | },
252 | },
253 | Object {
254 | "id": 5,
255 | "name": "Leaf 5",
256 | },
257 | ],
258 | "deepness": 0,
259 | "id": 0,
260 | "name": "Leaf 1",
261 | "parents": Array [],
262 | "state": Object {
263 | "expanded": true,
264 | },
265 | },
266 | "type": 0,
267 | }
268 | `;
269 |
270 | exports[`selectors -> nodes -> single node actions deleteNode should create the expected object 1`] = `
271 | Object {
272 | "node": Object {
273 | "children": Array [
274 | Object {
275 | "children": Array [
276 | Object {
277 | "children": Array [
278 | Object {
279 | "id": "c-3",
280 | "name": "Leaf 3 Child",
281 | "state": Object {},
282 | },
283 | ],
284 | "id": 3,
285 | "name": "Leaf 3",
286 | "state": Object {
287 | "deletable": true,
288 | "expanded": false,
289 | "favorite": true,
290 | },
291 | },
292 | Object {
293 | "id": 4,
294 | "name": "Leaf 4",
295 | },
296 | ],
297 | "id": 2,
298 | "name": "Leaf 2",
299 | "state": Object {
300 | "deletable": true,
301 | "expanded": true,
302 | },
303 | },
304 | Object {
305 | "id": 5,
306 | "name": "Leaf 5",
307 | },
308 | ],
309 | "deepness": 0,
310 | "id": 0,
311 | "name": "Leaf 1",
312 | "parents": Array [],
313 | "state": Object {
314 | "expanded": true,
315 | },
316 | },
317 | "type": 1,
318 | }
319 | `;
320 |
321 | exports[`selectors -> nodes -> single node actions updateNode should update the supplied node state without mutations 1`] = `
322 | Object {
323 | "node": Object {
324 | "children": Array [
325 | Object {
326 | "children": Array [
327 | Object {
328 | "children": Array [
329 | Object {
330 | "id": "c-3",
331 | "name": "Leaf 3 Child",
332 | "state": Object {},
333 | },
334 | ],
335 | "id": 3,
336 | "name": "Leaf 3",
337 | "state": Object {
338 | "deletable": true,
339 | "expanded": false,
340 | "favorite": true,
341 | },
342 | },
343 | Object {
344 | "id": 4,
345 | "name": "Leaf 4",
346 | },
347 | ],
348 | "id": 2,
349 | "name": "Leaf 2",
350 | "state": Object {
351 | "deletable": true,
352 | "expanded": true,
353 | },
354 | },
355 | Object {
356 | "id": 5,
357 | "name": "Leaf 5",
358 | },
359 | ],
360 | "deepness": 0,
361 | "id": 0,
362 | "name": "Leaf 1",
363 | "parents": Array [],
364 | "state": Object {
365 | "expanded": false,
366 | "favorite": true,
367 | },
368 | },
369 | "type": 2,
370 | }
371 | `;
372 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/selectors/__tests__/nodes.test.js:
--------------------------------------------------------------------------------
1 | import deepFreeze from 'deep-freeze';
2 |
3 | import * as nodeSelectors from '../nodes';
4 | import {Nodes} from '../../../testData/sampleTree';
5 | import {getFlattenedTree} from '../getFlattenedTree';
6 |
7 | describe('selectors -> nodes ->', () => {
8 | const getSampleNode = (i = 0) => getFlattenedTree(Nodes)[i];
9 |
10 | describe('single node actions', () => {
11 | it('updateNode should update the supplied node state without mutations', () => {
12 | const originalNode = getSampleNode();
13 |
14 | deepFreeze(originalNode);
15 |
16 | expect(nodeSelectors.updateNode(originalNode, {favorite: true, expanded: false})).toMatchSnapshot();
17 | });
18 |
19 | it('addNode should create the expected object', () => {
20 | expect(nodeSelectors.addNode(getSampleNode())).toMatchSnapshot();
21 | });
22 |
23 | it('deleteNode should create the expected object', () => {
24 | expect(nodeSelectors.deleteNode(getSampleNode())).toMatchSnapshot();
25 | });
26 | });
27 |
28 | describe('Tree actions', () => {
29 | describe('deleteNodeFromTree', () => {
30 | it('when root node is deleted, should delete that node and all children nodes', () => {
31 | deepFreeze(Nodes);
32 |
33 | expect(nodeSelectors.deleteNodeFromTree(Nodes, getSampleNode())).toMatchSnapshot();
34 | });
35 |
36 | it('when a node is deleted, should delete that node and all children nodes', () => {
37 | deepFreeze(Nodes);
38 |
39 | expect(nodeSelectors.deleteNodeFromTree(Nodes, getSampleNode(1))).toMatchSnapshot();
40 | });
41 | });
42 |
43 | it('replaceNodeFromTree should replace a node in the tree without mutations', () => {
44 | deepFreeze(Nodes);
45 |
46 | const updatedNode = nodeSelectors.updateNode(getSampleNode(), {favorite: true, expanded: false});
47 |
48 | expect(nodeSelectors.replaceNodeFromTree(Nodes, updatedNode.node)).toMatchSnapshot();
49 | });
50 | });
51 |
52 | describe('getNodeRenderOptions', () => {
53 | it('should extract state from nodes correctly when there are no children', () => {
54 | expect(nodeSelectors.getNodeRenderOptions({state: {expanded: true, favorite: true}})).toMatchSnapshot();
55 | });
56 |
57 | it('should extract state from nodes correctly when there are children', () => {
58 | expect(nodeSelectors.getNodeRenderOptions({children: [{}], state: {deletable: true}})).toMatchSnapshot();
59 | });
60 |
61 | it('should extract state from nodes correctly when there is no state', () => {
62 | expect(nodeSelectors.getNodeRenderOptions({children: [{}]})).toMatchSnapshot();
63 | });
64 | });
65 |
66 | describe('getNodeFromPath', () => {
67 | test('should get a node from a path in the root of the tree', () => {
68 | expect(nodeSelectors.getNodeFromPath([Nodes[0].id], Nodes)).toEqual(Nodes[0]);
69 | expect(nodeSelectors.getNodeFromPath([Nodes[1].id], Nodes)).toEqual(Nodes[1]);
70 | expect(nodeSelectors.getNodeFromPath([Nodes[2].id], Nodes)).toEqual(Nodes[2]);
71 | });
72 |
73 | test('should get a node from a path in the first set of children of the tree', () => {
74 | expect(nodeSelectors.getNodeFromPath([Nodes[0].id, Nodes[0].children[1].id], Nodes)).toEqual(
75 | Nodes[0].children[1],
76 | );
77 | });
78 |
79 | test('should get a node from a path deep in the tree', () => {
80 | expect(
81 | nodeSelectors.getNodeFromPath(
82 | [Nodes[0].id, Nodes[0].children[0].id, Nodes[0].children[0].children[1].id],
83 | Nodes,
84 | ),
85 | ).toEqual(Nodes[0].children[0].children[1]);
86 | });
87 |
88 | test('should throw custom error when the path is invalid', () => {
89 | expect(() => nodeSelectors.getNodeFromPath('', Nodes)).toThrowError('path is not an array');
90 | expect(() => nodeSelectors.getNodeFromPath({}, Nodes)).toThrowError('path is not an array');
91 | expect(() => nodeSelectors.getNodeFromPath(1245, Nodes)).toThrowError('path is not an array');
92 | expect(() => nodeSelectors.getNodeFromPath(true, Nodes)).toThrowError('path is not an array');
93 | });
94 |
95 | test('should throw custom error when path does not exist in a middle node', () => {
96 | const {id: existingId1} = Nodes[0];
97 | const {id: existingId2} = Nodes[0].children[0].children[1];
98 |
99 | expect(() => nodeSelectors.getNodeFromPath([existingId1, 25, existingId2], Nodes)).toThrowError(
100 | `Could not find node at ${existingId1},25,${existingId2}`,
101 | );
102 | });
103 |
104 | test('should throw custom error when path does not exist in the final node', () => {
105 | const {id: existingId1} = Nodes[0];
106 | const {id: existingId2} = Nodes[0].children[0];
107 |
108 | expect(() => nodeSelectors.getNodeFromPath([existingId1, existingId2, 25], Nodes)).toThrowError(
109 | `Could not find node at ${existingId1},${existingId2},25`,
110 | );
111 | });
112 | });
113 | });
114 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/selectors/nodes.js:
--------------------------------------------------------------------------------
1 | import {createSelector} from 'reselect';
2 | import omit from 'lodash.omit';
3 | import findIndex from 'lodash.findindex';
4 |
5 | import {UPDATE_TYPE} from '../contants';
6 |
7 | export {getFlattenedTree} from './getFlattenedTree';
8 |
9 | export const getNodeRenderOptions = createSelector(
10 | node => (node.state || {}).expanded,
11 | node => (node.state || {}).favorite,
12 | node => (node.state || {}).deletable,
13 | node => node.children,
14 | (expanded, favorite, deletable, children = []) => ({
15 | hasChildren: !!children.length,
16 | isExpanded: !!expanded,
17 | isFavorite: !!favorite,
18 | isDeletable: !!deletable,
19 | }),
20 | );
21 |
22 | const FLATTEN_TREE_PROPERTIES = ['deepness', 'parents'];
23 |
24 | const NODE_OPERATION_TYPES = {
25 | CHANGE_NODE: 'CHANGE_NODE',
26 | DELETE_NODE: 'DELETE_NODE',
27 | };
28 |
29 | const NODE_CHANGE_OPERATIONS = {
30 | CHANGE_NODE: (nodes, updatedNode) =>
31 | nodes.map(
32 | n =>
33 | n.id === updatedNode.id
34 | ? omit({...updatedNode, ...(n.children && {children: [...n.children]})}, FLATTEN_TREE_PROPERTIES)
35 | : n,
36 | ),
37 | DELETE_NODE: (nodes, updatedNode) => nodes.filter(n => n.id !== updatedNode.id),
38 | };
39 |
40 | export const replaceNodeFromTree = (nodes, updatedNode, operation = NODE_OPERATION_TYPES.CHANGE_NODE) => {
41 | if (!NODE_CHANGE_OPERATIONS[operation]) {
42 | return nodes;
43 | }
44 |
45 | const {parents} = updatedNode;
46 |
47 | if (!parents.length) {
48 | return NODE_CHANGE_OPERATIONS[operation](nodes, updatedNode);
49 | }
50 |
51 | const parentIndex = findIndex(nodes, n => n.id === parents[0]);
52 | const preSiblings = nodes.slice(0, parentIndex);
53 | const postSiblings = nodes.slice(parentIndex + 1);
54 |
55 | return [
56 | ...preSiblings,
57 | {
58 | ...nodes[parentIndex],
59 | ...(nodes[parentIndex].children
60 | ? {
61 | children: replaceNodeFromTree(
62 | nodes[parentIndex].children,
63 | {...updatedNode, parents: parents.slice(1)},
64 | operation,
65 | ),
66 | }
67 | : {}),
68 | },
69 | ...postSiblings,
70 | ];
71 | };
72 |
73 | export const deleteNodeFromTree = (nodes, deletedNode) => {
74 | return replaceNodeFromTree(nodes, deletedNode, NODE_OPERATION_TYPES.DELETE_NODE);
75 | };
76 |
77 | export const updateNode = (originalNode, newState) => ({
78 | node: {
79 | ...originalNode,
80 | state: {
81 | ...originalNode.state,
82 | ...newState,
83 | },
84 | },
85 | type: UPDATE_TYPE.UPDATE,
86 | });
87 |
88 | export const deleteNode = node => ({
89 | node,
90 | type: UPDATE_TYPE.DELETE,
91 | });
92 |
93 | export const addNode = node => ({
94 | node,
95 | type: UPDATE_TYPE.ADD,
96 | });
97 |
98 | export const getRowIndexFromId = (flattenedTree, id) => findIndex(flattenedTree, node => node.id === id);
99 |
100 | /**
101 | * Gets a node in the original tree from a provided path.
102 | *
103 | * @param {number|string[]} path - The id path to the node
104 | * @param {Object[]} tree - The Original tree
105 | */
106 | export const getNodeFromPath = (path, tree) => {
107 | let node;
108 | let nextLevel = tree;
109 |
110 | if (!Array.isArray(path)) {
111 | throw new Error('path is not an array');
112 | }
113 |
114 | for (let i = 0; i < path.length; i++) {
115 | const id = path[i];
116 |
117 | let nextNode = nextLevel.find(n => n.id === id);
118 |
119 | if (!nextNode) {
120 | throw new Error(`Could not find node at ${path.join(',')}`);
121 | }
122 |
123 | if (i === path.length - 1 && nextNode.id === id) {
124 | node = nextNode;
125 | } else {
126 | nextLevel = nextNode.children;
127 | }
128 | }
129 |
130 | if (!node) {
131 | throw new Error(`Could not find node at ${path.join(',')}`);
132 | }
133 |
134 | return node;
135 | };
136 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | jest.unmock('react-virtualized');
2 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/state/TreeState.js:
--------------------------------------------------------------------------------
1 | import {getFlattenedTreePaths} from '../selectors/getFlattenedTree';
2 | import {getNodeFromPath} from '../selectors/nodes';
3 |
4 | export class State {
5 | flattenedTree = null;
6 | tree = null;
7 |
8 | constructor(tree, flattenedTree) {
9 | this.tree = tree;
10 | this.flattenedTree = flattenedTree || getFlattenedTreePaths(tree);
11 | }
12 | }
13 |
14 | export const validateState = state => {
15 | if (!(state instanceof State)) {
16 | throw new Error(`Expected a State instance but got ${typeof state}`);
17 | }
18 | };
19 |
20 | /**
21 | * Immutable structure that represents the TreeState.
22 | */
23 | export default class TreeState {
24 | /**
25 | * Given a state, finds a node at a certain row index.
26 | * @param {State} state - The current state
27 | * @param {number} index - The visible row index
28 | * @return {State} An internal state representation
29 | */
30 | static getNodeAt = (state, index) => {
31 | validateState(state);
32 |
33 | const rowPath = state.flattenedTree[index];
34 |
35 | if (!rowPath) {
36 | throw Error(
37 | `Tried to get node at row "${index}" but got nothing, the tree are ${state.flattenedTree.length} visible rows`,
38 | );
39 | }
40 |
41 | return getNodeFromPath(rowPath, state.tree);
42 | };
43 |
44 | /**
45 | * Given a state, finds a node deepness at a certain row index.
46 | * @param {State} state - The current state
47 | * @param {number} index - The visible row index
48 | * @return {number} The node deepness
49 | */
50 | static getNodeDeepness = (state, index) => {
51 | validateState(state);
52 |
53 | const rowPath = state.flattenedTree[index];
54 |
55 | if (!rowPath) {
56 | throw Error(
57 | `Tried to get node at row "${index}" but got nothing, the tree are ${state.flattenedTree.length} visible rows`,
58 | );
59 | }
60 |
61 | return rowPath.length - 1;
62 | };
63 |
64 | /**
65 | * Given a state and an index, finds the number of visible descendants
66 | * @param {State} state - The current state
67 | * @param {number} index - The visible row index
68 | * @return {number} The number of visible descendants
69 | */
70 | static getNumberOfVisibleDescendants = (state, index) => {
71 | const {id} = TreeState.getNodeAt(state, index);
72 |
73 | const {flattenedTree} = state;
74 | let i;
75 |
76 | for (i = index; i < flattenedTree.length; i++) {
77 | const path = flattenedTree[i];
78 |
79 | if (!path.some(p => p === id)) {
80 | break;
81 | }
82 | }
83 |
84 | return Math.max(i - 1 - index, 0);
85 | };
86 |
87 | /**
88 | * Given a state, gets the tree
89 | * @param {State} state - The current state
90 | * @return {Node[]} The tree
91 | */
92 | static getTree = state => {
93 | validateState(state);
94 |
95 | return state.tree;
96 | };
97 |
98 | /**
99 | * Creates an instance of state.
100 | * @param {Node[]} tree - The original tree
101 | * @return {State} An internal state representation
102 | */
103 | static createFromTree = tree => {
104 | if (!tree) {
105 | throw Error('A falsy tree was supplied in tree creation');
106 | }
107 |
108 | if (!Array.isArray(tree)) {
109 | throw Error('An invalid tree was supplied in creation');
110 | }
111 |
112 | return new State(tree);
113 | };
114 | }
115 |
--------------------------------------------------------------------------------
/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/state/__tests__/TreeState.test.js:
--------------------------------------------------------------------------------
1 | import TreeState from '../TreeState';
2 | import {Nodes} from '../../../testData/sampleTree';
3 |
4 | describe('TreeState', () => {
5 | describe('createFromTree', () => {
6 | test('should fail when falsy value is supplied', () => {
7 | expect(() => TreeState.createFromTree()).toThrowError('A falsy tree was supplied in tree creation');
8 | expect(() => TreeState.createFromTree('')).toThrowError('A falsy tree was supplied in tree creation');
9 | expect(() => TreeState.createFromTree(0)).toThrowError('A falsy tree was supplied in tree creation');
10 | expect(() => TreeState.createFromTree(false)).toThrowError('A falsy tree was supplied in tree creation');
11 | });
12 |
13 | test('should fail when an invalid value is supplied', () => {
14 | expect(() => TreeState.createFromTree({})).toThrowError('An invalid tree was supplied in creation');
15 | expect(() => TreeState.createFromTree('tree')).toThrowError('An invalid tree was supplied in creation');
16 | expect(() => TreeState.createFromTree(1234)).toThrowError('An invalid tree was supplied in creation');
17 | expect(() => TreeState.createFromTree(true)).toThrowError('An invalid tree was supplied in creation');
18 | });
19 |
20 | test('should create state when a valid tree is supplied', () => {
21 | const {tree, flattenedTree} = TreeState.createFromTree(Nodes);
22 |
23 | expect(tree).toEqual(Nodes);
24 | expect(flattenedTree).toMatchSnapshot();
25 | });
26 | });
27 |
28 | describe('getNodeAt', () => {
29 | test('should get a for an existing rowId', () => {
30 | expect(TreeState.getNodeAt(TreeState.createFromTree(Nodes), 0)).toBe(Nodes[0]);
31 | expect(TreeState.getNodeAt(TreeState.createFromTree(Nodes), 1)).toMatchSnapshot('2nd row');
32 | expect(TreeState.getNodeAt(TreeState.createFromTree(Nodes), 2)).toMatchSnapshot('3rd row');
33 | expect(TreeState.getNodeAt(TreeState.createFromTree(Nodes), 6)).toMatchSnapshot('7th row');
34 | });
35 |
36 | test('should fail with a custom error when supplied rowId does not exist', () => {
37 | expect(() => TreeState.getNodeAt(TreeState.createFromTree(Nodes), 25)).toThrowErrorMatchingSnapshot();
38 | });
39 |
40 | test('should fail for when invalid state is supplied', () => {
41 | expect(() => TreeState.getNodeAt('state', 0)).toThrowError('Expected a State instance but got string');
42 | expect(() => TreeState.getNodeAt(1225, 0)).toThrowError('Expected a State instance but got number');
43 | expect(() => TreeState.getNodeAt([], 0)).toThrowError('Expected a State instance but got object');
44 | expect(() => TreeState.getNodeAt({}, 0)).toThrowError('Expected a State instance but got object');
45 | expect(() => TreeState.getNodeAt(true, 0)).toThrowError('Expected a State instance but got boolean');
46 | expect(() => TreeState.getNodeAt(() => {}, 0)).toThrowError('Expected a State instance but got function');
47 | });
48 | });
49 |
50 | describe('getTree', () => {
51 | test('should get a tree', () => {
52 | expect(TreeState.getTree(TreeState.createFromTree(Nodes))).toEqual(Nodes);
53 | });
54 |
55 | test('should fail for when invalid state is supplied', () => {
56 | expect(() => TreeState.getTree('state')).toThrowError('Expected a State instance but got string');
57 | expect(() => TreeState.getTree(1225)).toThrowError('Expected a State instance but got number');
58 | expect(() => TreeState.getTree([])).toThrowError('Expected a State instance but got object');
59 | expect(() => TreeState.getTree({})).toThrowError('Expected a State instance but got object');
60 | expect(() => TreeState.getTree(true)).toThrowError('Expected a State instance but got boolean');
61 | expect(() => TreeState.getTree(() => {})).toThrowError('Expected a State instance but got function');
62 | });
63 | });
64 |
65 | describe('getNodeDeepness', () => {
66 | test('should get the correct deepness for existing rowId', () => {
67 | expect(TreeState.getNodeDeepness(TreeState.createFromTree(Nodes), 0)).toBe(0);
68 | expect(TreeState.getNodeDeepness(TreeState.createFromTree(Nodes), 1)).toBe(1);
69 | expect(TreeState.getNodeDeepness(TreeState.createFromTree(Nodes), 2)).toBe(2);
70 | expect(TreeState.getNodeDeepness(TreeState.createFromTree(Nodes), 3)).toBe(2);
71 | expect(TreeState.getNodeDeepness(TreeState.createFromTree(Nodes), 6)).toBe(0);
72 | });
73 |
74 | test('should fail with a custom error when supplied rowId does not exist', () => {
75 | expect(() => TreeState.getNodeDeepness(TreeState.createFromTree(Nodes), 40)).toThrowErrorMatchingSnapshot();
76 | });
77 |
78 | test('should fail for when invalid state is supplied', () => {
79 | expect(() => TreeState.getNodeDeepness('state', 0)).toThrowError('Expected a State instance but got string');
80 | expect(() => TreeState.getNodeDeepness(1225, 0)).toThrowError('Expected a State instance but got number');
81 | expect(() => TreeState.getNodeDeepness([], 0)).toThrowError('Expected a State instance but got object');
82 | expect(() => TreeState.getNodeDeepness({}, 0)).toThrowError('Expected a State instance but got object');
83 | expect(() => TreeState.getNodeDeepness(true, 0)).toThrowError('Expected a State instance but got boolean');
84 | expect(() => TreeState.getNodeDeepness(() => {}, 0)).toThrowError('Expected a State instance but got function');
85 | });
86 | });
87 |
88 | describe('getNumberOfVisibleDescendants', () => {
89 | test('should fail for when invalid state is supplied', () => {
90 | expect(() => TreeState.getNumberOfVisibleDescendants('state', 0)).toThrowError(
91 | 'Expected a State instance but got string',
92 | );
93 | expect(() => TreeState.getNumberOfVisibleDescendants(1225, 0)).toThrowError(
94 | 'Expected a State instance but got number',
95 | );
96 | expect(() => TreeState.getNumberOfVisibleDescendants([], 0)).toThrowError(
97 | 'Expected a State instance but got object',
98 | );
99 | expect(() => TreeState.getNumberOfVisibleDescendants({}, 0)).toThrowError(
100 | 'Expected a State instance but got object',
101 | );
102 | expect(() => TreeState.getNumberOfVisibleDescendants(true, 0)).toThrowError(
103 | 'Expected a State instance but got boolean',
104 | );
105 | expect(() => TreeState.getNumberOfVisibleDescendants(() => {}, 0)).toThrowError(
106 | 'Expected a State instance but got function',
107 | );
108 | });
109 |
110 | test('should get a correct number of descendants for a node with deep descendants', () => {
111 | expect(TreeState.getNumberOfVisibleDescendants(TreeState.createFromTree(Nodes), 0)).toEqual(4);
112 | });
113 |
114 | test('should get a correct number of descendants for a node without grand children', () => {
115 | expect(TreeState.getNumberOfVisibleDescendants(TreeState.createFromTree(Nodes), 1)).toEqual(2);
116 | });
117 |
118 | test('should get a correct number of descendants for a node with deep descendants', () => {
119 | expect(TreeState.getNumberOfVisibleDescendants(TreeState.createFromTree(Nodes), 0)).toEqual(4);
120 | });
121 |
122 | test('should get 0 descendants for a node that does not have any descendants in the root node', () => {
123 | expect(TreeState.getNumberOfVisibleDescendants(TreeState.createFromTree(Nodes), 6)).toEqual(0);
124 | });
125 |
126 | test('should get 0 descendants for a node that does not have any descendants', () => {
127 | expect(TreeState.getNumberOfVisibleDescendants(TreeState.createFromTree(Nodes), 3)).toEqual(0);
128 | });
129 | });
130 | });
131 |
--------------------------------------------------------------------------------
/src/state/__tests__/TreeStateModifiers.test.js:
--------------------------------------------------------------------------------
1 | import deepFreeze from 'deep-freeze';
2 | import {diff} from 'deep-diff';
3 |
4 | import TreeStateModifiers from '../TreeStateModifiers';
5 | import {Nodes} from '../../../testData/sampleTree';
6 | import TreeState from '../TreeState';
7 |
8 | describe('TreeStateModifiers', () => {
9 | const noop = () => {};
10 |
11 | describe('editNodeAt', () => {
12 | test('should fail when invalid state is supplied', () => {
13 | expect(() => TreeStateModifiers.editNodeAt('state', 0, noop)).toThrowError(
14 | 'Expected a State instance but got string',
15 | );
16 | expect(() => TreeStateModifiers.editNodeAt(1225, 0, noop)).toThrowError(
17 | 'Expected a State instance but got number',
18 | );
19 | expect(() => TreeStateModifiers.editNodeAt([], 0, noop)).toThrowError('Expected a State instance but got object');
20 | expect(() => TreeStateModifiers.editNodeAt({}, 0, noop)).toThrowError('Expected a State instance but got object');
21 | expect(() => TreeStateModifiers.editNodeAt(true, 0, noop)).toThrowError(
22 | 'Expected a State instance but got boolean',
23 | );
24 | expect(() => TreeStateModifiers.editNodeAt(() => {}, 0, noop)).toThrowError(
25 | 'Expected a State instance but got function',
26 | );
27 | });
28 |
29 | test('should fail with descriptive error when node at index does not exist', () => {
30 | expect(() =>
31 | TreeStateModifiers.editNodeAt(TreeState.createFromTree(Nodes), 20, noop),
32 | ).toThrowErrorMatchingSnapshot();
33 | });
34 |
35 | describe('flattened tree', () => {
36 | test('should collapse a node in a root node', () => {
37 | const state = TreeState.createFromTree(Nodes);
38 |
39 | deepFreeze(state);
40 |
41 | const {flattenedTree} = TreeStateModifiers.editNodeAt(state, 0, n => ({
42 | ...n,
43 | state: {...n.state, expanded: false},
44 | }));
45 |
46 | expect(flattenedTree).toMatchSnapshot();
47 | });
48 |
49 | test('should collapse a node in a children node', () => {
50 | const state = TreeState.createFromTree(Nodes);
51 |
52 | deepFreeze(state);
53 |
54 | const {flattenedTree} = TreeStateModifiers.editNodeAt(state, 1, n => ({
55 | ...n,
56 | state: {...n.state, expanded: false},
57 | }));
58 |
59 | expect(flattenedTree).toMatchSnapshot();
60 | });
61 |
62 | test('should expand a node in a root node', () => {
63 | const state = TreeState.createFromTree(Nodes);
64 |
65 | deepFreeze(state);
66 |
67 | const {flattenedTree} = TreeStateModifiers.editNodeAt(state, 5, n => ({
68 | ...n,
69 | state: {...n.state, expanded: true},
70 | }));
71 |
72 | expect(flattenedTree).toMatchSnapshot();
73 | });
74 |
75 | test('should expand a node in a children node', () => {
76 | const state = TreeState.createFromTree(Nodes);
77 |
78 | deepFreeze(state);
79 |
80 | const {flattenedTree} = TreeStateModifiers.editNodeAt(state, 2, n => ({
81 | ...n,
82 | state: {...n.state, expanded: true},
83 | }));
84 |
85 | expect(flattenedTree).toMatchSnapshot();
86 | });
87 |
88 | test('should not change for updates that do not change state', () => {
89 | const state = TreeState.createFromTree(Nodes);
90 |
91 | deepFreeze(state);
92 |
93 | const {flattenedTree} = TreeStateModifiers.editNodeAt(state, 2, n => ({
94 | ...n,
95 | name: 'node',
96 | }));
97 |
98 | expect(flattenedTree).toEqual(state.flattenedTree);
99 | });
100 |
101 | test('should not change for updates that change state but not expansion', () => {
102 | const state = TreeState.createFromTree(Nodes);
103 |
104 | deepFreeze(state);
105 |
106 | const {flattenedTree} = TreeStateModifiers.editNodeAt(state, 2, n => ({
107 | ...n,
108 | state: {...n.state, favorite: true},
109 | }));
110 |
111 | expect(flattenedTree).toEqual(state.flattenedTree);
112 |
113 | const {flattenedTree: flattenedTree2} = TreeStateModifiers.editNodeAt(state, 0, n => ({
114 | ...n,
115 | state: {...n.state, deletable: true},
116 | }));
117 |
118 | expect(flattenedTree2).toEqual(state.flattenedTree);
119 |
120 | const {flattenedTree: flattenedTree3} = TreeStateModifiers.editNodeAt(state, 0, n => ({
121 | ...n,
122 | state: {...n.state, randomKey: true},
123 | }));
124 |
125 | expect(flattenedTree3).toEqual(state.flattenedTree);
126 | });
127 | });
128 |
129 | describe('tree', () => {
130 | test('should update a node in the root and keep the rest intact', () => {
131 | const state = TreeState.createFromTree(Nodes);
132 |
133 | deepFreeze(state);
134 |
135 | const updatedName = 'Edit node 1';
136 |
137 | // Change 'Leaf 1'
138 | const {tree} = TreeStateModifiers.editNodeAt(state, 0, n => ({
139 | ...n,
140 | name: updatedName,
141 | }));
142 |
143 | const changes = diff(state.tree, tree);
144 |
145 | expect(changes.length).toBe(1);
146 | expect(changes[0]).toMatchSnapshot();
147 | });
148 |
149 | test('should update a child node and keep the rest intact', () => {
150 | const state = TreeState.createFromTree(Nodes);
151 |
152 | deepFreeze(state);
153 |
154 | const updatedName = 'Edited node';
155 |
156 | // Change 'Leaf 3'
157 | const {tree} = TreeStateModifiers.editNodeAt(state, 2, n => ({
158 | ...n,
159 | name: updatedName,
160 | }));
161 |
162 | const changes = diff(state.tree, tree);
163 |
164 | expect(changes.length).toBe(1);
165 | expect(changes[0]).toMatchSnapshot();
166 | });
167 |
168 | test('should update a node state in the root and keep the rest intact', () => {
169 | const state = TreeState.createFromTree(Nodes);
170 |
171 | deepFreeze(state);
172 |
173 | // Expand 'Leaf 6'
174 | const {tree} = TreeStateModifiers.editNodeAt(state, 5, n => ({
175 | ...n,
176 | state: {expanded: true},
177 | }));
178 |
179 | const changes = diff(state.tree, tree);
180 |
181 | expect(changes).toMatchSnapshot();
182 | });
183 |
184 | test('should update a child node state and keep the rest intact', () => {
185 | const state = TreeState.createFromTree(Nodes);
186 |
187 | deepFreeze(state);
188 |
189 | // Collapse 'Leaf 2'
190 | const {tree} = TreeStateModifiers.editNodeAt(state, 1, n => ({
191 | ...n,
192 | state: {...n.state, expanded: false},
193 | }));
194 |
195 | const changes = diff(state.tree, tree);
196 |
197 | expect(changes.length).toBe(1);
198 | expect(changes[0]).toMatchSnapshot();
199 | });
200 |
201 | test('should create state for a child node and keep the rest intact', () => {
202 | const state = TreeState.createFromTree(Nodes);
203 |
204 | deepFreeze(state);
205 |
206 | // Favorite 'Leaf 5'
207 | const {tree} = TreeStateModifiers.editNodeAt(state, 4, n => ({
208 | ...n,
209 | state: {...n.state, expanded: true},
210 | }));
211 |
212 | const changes = diff(state.tree, tree);
213 |
214 | expect(changes.length).toBe(1);
215 | expect(changes[0]).toMatchSnapshot();
216 | });
217 |
218 | test('should delete state for a root node and keep the rest intact', () => {
219 | const state = TreeState.createFromTree(Nodes);
220 |
221 | deepFreeze(state);
222 |
223 | // Clear state for 'Leaf 6'
224 | const {tree} = TreeStateModifiers.editNodeAt(state, 5, n => {
225 | return Object.keys(n)
226 | .filter(k => k !== 'state')
227 | .reduce((node, k) => ({...node, [k]: n[k]}), {});
228 | });
229 |
230 | const changes = diff(state.tree, tree);
231 |
232 | expect(changes.length).toBe(1);
233 | expect(changes[0]).toMatchSnapshot();
234 | });
235 |
236 | test('should delete state for a child node and keep the rest intact', () => {
237 | const state = TreeState.createFromTree(Nodes);
238 |
239 | deepFreeze(state);
240 |
241 | // Clear state for 'Leaf 3'
242 | const {tree} = TreeStateModifiers.editNodeAt(state, 2, n => {
243 | return Object.keys(n)
244 | .filter(k => k !== 'state')
245 | .reduce((node, k) => ({...node, [k]: n[k]}), {});
246 | });
247 |
248 | const changes = diff(state.tree, tree);
249 |
250 | expect(changes.length).toBe(1);
251 | expect(changes[0]).toMatchSnapshot();
252 | });
253 | });
254 | });
255 |
256 | describe('deleteNodeAt', () => {
257 | test('should fail when invalid state is supplied', () => {
258 | expect(() => TreeStateModifiers.deleteNodeAt('state', 0)).toThrowError(
259 | 'Expected a State instance but got string',
260 | );
261 | expect(() => TreeStateModifiers.deleteNodeAt(1225, 0)).toThrowError('Expected a State instance but got number');
262 | expect(() => TreeStateModifiers.deleteNodeAt([], 0)).toThrowError('Expected a State instance but got object');
263 | expect(() => TreeStateModifiers.deleteNodeAt({}, 0)).toThrowError('Expected a State instance but got object');
264 | expect(() => TreeStateModifiers.deleteNodeAt(true, 0)).toThrowError('Expected a State instance but got boolean');
265 | expect(() => TreeStateModifiers.deleteNodeAt(() => {}, 0)).toThrowError(
266 | 'Expected a State instance but got function',
267 | );
268 | });
269 |
270 | test('should fail with descriptive error when node at index does not exist', () => {
271 | expect(() => TreeStateModifiers.deleteNodeAt(TreeState.createFromTree(Nodes), 20)).toThrowErrorMatchingSnapshot();
272 | });
273 |
274 | describe('flattened tree', () => {
275 | test('should delete a root node with expanded children', () => {
276 | const state = TreeState.createFromTree(Nodes);
277 |
278 | deepFreeze(state);
279 |
280 | const {flattenedTree} = TreeStateModifiers.deleteNodeAt(state, 0);
281 |
282 | expect(flattenedTree).toMatchSnapshot();
283 | });
284 |
285 | test('should delete a root node without expanded children', () => {
286 | const state = TreeState.createFromTree(Nodes);
287 |
288 | deepFreeze(state);
289 |
290 | const {flattenedTree} = TreeStateModifiers.deleteNodeAt(state, 5);
291 |
292 | expect(flattenedTree).toMatchSnapshot();
293 | });
294 |
295 | test('should delete a child node with expanded children', () => {
296 | const state = TreeState.createFromTree(Nodes);
297 |
298 | deepFreeze(state);
299 |
300 | const {flattenedTree} = TreeStateModifiers.deleteNodeAt(state, 1);
301 |
302 | expect(flattenedTree).toMatchSnapshot();
303 | });
304 |
305 | test('should delete a child node without expanded children', () => {
306 | const state = TreeState.createFromTree(Nodes);
307 |
308 | deepFreeze(state);
309 |
310 | const {flattenedTree} = TreeStateModifiers.deleteNodeAt(state, 2);
311 |
312 | expect(flattenedTree).toMatchSnapshot();
313 | });
314 | });
315 |
316 | describe('tree', () => {
317 | test('should delete a root node with expanded children', () => {
318 | const state = TreeState.createFromTree(Nodes);
319 |
320 | deepFreeze(state);
321 |
322 | const {tree} = TreeStateModifiers.deleteNodeAt(state, 0);
323 |
324 | const changes = diff(state.tree, tree);
325 |
326 | expect(changes).toMatchSnapshot();
327 | });
328 |
329 | test('should delete a root node without expanded children', () => {
330 | const state = TreeState.createFromTree(Nodes);
331 |
332 | deepFreeze(state);
333 |
334 | const {tree} = TreeStateModifiers.deleteNodeAt(state, 6);
335 |
336 | const changes = diff(state.tree, tree);
337 |
338 | expect(changes).toMatchSnapshot();
339 | });
340 |
341 | test('should delete a child node without expanded children', () => {
342 | const state = TreeState.createFromTree(Nodes);
343 |
344 | deepFreeze(state);
345 |
346 | const {tree} = TreeStateModifiers.deleteNodeAt(state, 2);
347 |
348 | const changes = diff(state.tree, tree);
349 |
350 | expect(changes).toMatchSnapshot();
351 | });
352 |
353 | test('should delete a child node with expanded children', () => {
354 | const state = TreeState.createFromTree(Nodes);
355 |
356 | deepFreeze(state);
357 |
358 | const {tree} = TreeStateModifiers.deleteNodeAt(state, 1);
359 |
360 | const changes = diff(state.tree, tree);
361 |
362 | expect(changes).toMatchSnapshot();
363 | });
364 | });
365 | });
366 | });
367 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------