├── .gitignore
├── .prettierrc
├── getSortedParentChildrenMap.js
├── example
├── entry.js
├── webpack.config.js
├── getExampleTree.js
├── HtmlTree.js
├── App.js
└── TextTree.js
├── getRoots.spec.js
├── getRoots.js
├── getParentChildrenMap.js
├── .eslintrc.yaml
├── Makefile
├── getChildParentMap.js
├── getDescendents.js
├── getRenderList.spec.js
├── getParentChildrenMap.spec.js
├── getChildParentMap.spec.js
├── getLeaves.spec.js
├── getSortedParentChildrenMap.spec.js
├── getLeaves.js
├── traverseDepthFirst.js
├── getDescendents.spec.js
├── getNode.spec.js
├── test.js
├── README.md
├── traverseDepthFirst.spec.js
├── getTree.spec.js
├── karma.conf.js
├── package.json
├── getNode.js
├── getRenderList.js
├── __snapshots__
└── getRenderList().md
└── getTree.js
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | printWidth: 100
2 | semi: false
3 | singleQuote: true
4 | tabWidth: 4
5 | trailingComma: es5
6 |
--------------------------------------------------------------------------------
/getSortedParentChildrenMap.js:
--------------------------------------------------------------------------------
1 | module.exports = function getSortedParentChildrenMap(parentChildrenMap, comparator) {
2 | return parentChildrenMap.map(children => children.toList().sort(comparator))
3 | }
4 |
--------------------------------------------------------------------------------
/example/entry.js:
--------------------------------------------------------------------------------
1 | const React = require('react')
2 | const { render } = require('react-dom')
3 | const App = require('./App')
4 |
5 | document.addEventListener('DOMContentLoaded', function() {
6 | render(, document.body.appendChild(document.createElement('div')))
7 | })
8 |
--------------------------------------------------------------------------------
/getRoots.spec.js:
--------------------------------------------------------------------------------
1 | var { expect } = require('chai')
2 | const { Set } = require('immutable')
3 | const getRoots = require('./getRoots')
4 |
5 | describe('getRoots()', function() {
6 | it('should get the roots', function() {
7 | expect(getRoots(this.parentChildrenMap)).to.equal(new Set(['a0']))
8 | })
9 | })
10 |
--------------------------------------------------------------------------------
/getRoots.js:
--------------------------------------------------------------------------------
1 | const isEmpty = require('lodash/isEmpty')
2 | const getChildParentMap = require('./getChildParentMap')
3 |
4 | module.exports = function getRoots(parentChildrenMap) {
5 | return getChildParentMap(parentChildrenMap)
6 | .filter(parentId => isEmpty(parentId))
7 | .keySeq()
8 | .toSet()
9 | }
10 |
--------------------------------------------------------------------------------
/getParentChildrenMap.js:
--------------------------------------------------------------------------------
1 | const { Map, Set } = require('immutable')
2 |
3 | module.exports = function getParentChildrenMap(childParentMap) {
4 | return childParentMap.reduce((acc, parentId, nodeId) => {
5 | if (!parentId) return acc
6 | return acc.update(parentId, (children = new Set()) => children.add(nodeId))
7 | }, new Map())
8 | }
9 |
--------------------------------------------------------------------------------
/.eslintrc.yaml:
--------------------------------------------------------------------------------
1 | env:
2 | browser: true
3 | commonjs: true
4 | node: true
5 | extends:
6 | - prettier
7 | - eslint:recommended
8 | - plugin:react/recommended
9 | globals:
10 | beforeEach: true
11 | describe: true
12 | it: true
13 | parser: babel-eslint
14 | parserOptions:
15 | ecmaFeatures:
16 | jsx: true
17 | plugins:
18 | - react
19 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | style:
2 | node_modules/.bin/eslint '**/*.js'
3 |
4 | styled:
5 | node_modules/.bin/prettier '**/*.js' --write
6 | node_modules/.bin/eslint '**/*.js' --fix
7 |
8 | testd:
9 | node_modules/.bin/karma start karma.conf.js
10 |
11 | web:
12 | node_modules/.bin/webpack \
13 | --config=./example/webpack.config.js
14 |
15 | webd:
16 | node_modules/.bin/webpack-dev-server \
17 | --config=./example/webpack.config.js
18 |
--------------------------------------------------------------------------------
/getChildParentMap.js:
--------------------------------------------------------------------------------
1 | const { Map } = require('immutable')
2 |
3 | module.exports = function getChildParentMap(parentChildrenMap) {
4 | return parentChildrenMap.reduce(
5 | (acc, children, parentId) =>
6 | children.reduce(
7 | (_acc, childId) => _acc.set(childId, parentId),
8 | !acc.has(parentId) ? acc.set(parentId) : acc
9 | ),
10 | new Map()
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/getDescendents.js:
--------------------------------------------------------------------------------
1 | const { Set } = require('immutable')
2 | const traverseDepthFirst = require('./traverseDepthFirst')
3 |
4 | module.exports = function getDescendents({ depth, nodeId, parentChildrenMap }) {
5 | return new Set()
6 | .withMutations(mutable =>
7 | traverseDepthFirst(
8 | {
9 | depth,
10 | nodeId,
11 | parentChildrenMap,
12 | },
13 | nodeId => mutable.add(nodeId)
14 | )
15 | )
16 | .delete(nodeId)
17 | }
18 |
--------------------------------------------------------------------------------
/getRenderList.spec.js:
--------------------------------------------------------------------------------
1 | var { expect } = require('chai')
2 | const getRenderList = require('./getRenderList')
3 | const getRoots = require('./getRoots')
4 | const getSortedParentChildrenMap = require('./getSortedParentChildrenMap')
5 |
6 | describe('getRenderList()', function() {
7 | it('should get the render list', function() {
8 | expect(
9 | getRenderList({
10 | roots: getRoots(this.parentChildrenMap),
11 | sortedParentChildrenMap: getSortedParentChildrenMap(this.parentChildrenMap),
12 | })
13 | ).to.matchSnapshot()
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/getParentChildrenMap.spec.js:
--------------------------------------------------------------------------------
1 | var { expect } = require('chai')
2 | const { Map, Set } = require('immutable')
3 | const getParentChildrenMap = require('./getParentChildrenMap')
4 |
5 | describe('getParentChildrenMap()', function() {
6 | it('should get the parent-children map', function() {
7 | expect(getParentChildrenMap(this.childParentMap)).to.equal(
8 | new Map({
9 | a0: new Set(['b1', 'a1']),
10 | a1: new Set(['b2', 'a2']),
11 | b1: new Set(['d2', 'c2']),
12 | b2: new Set(['b3', 'a3']),
13 | })
14 | )
15 | })
16 | })
17 |
--------------------------------------------------------------------------------
/getChildParentMap.spec.js:
--------------------------------------------------------------------------------
1 | var { expect } = require('chai')
2 | const { Map } = require('immutable')
3 | const getChildParentMap = require('./getChildParentMap')
4 |
5 | describe('getChildParentMap()', function() {
6 | it('should get the child-parent map', function() {
7 | expect(getChildParentMap(this.parentChildrenMap)).to.equal(
8 | new Map({
9 | a0: undefined,
10 | a1: 'a0',
11 | b1: 'a0',
12 | a2: 'a1',
13 | b2: 'a1',
14 | c2: 'b1',
15 | d2: 'b1',
16 | a3: 'b2',
17 | b3: 'b2',
18 | })
19 | )
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/getLeaves.spec.js:
--------------------------------------------------------------------------------
1 | var { expect } = require('chai')
2 | const { Set } = require('immutable')
3 | const getLeaves = require('./getLeaves')
4 |
5 | describe('getLeaves()', function() {
6 | it('should get all leaves', function() {
7 | expect(
8 | getLeaves({
9 | parentChildrenMap: this.parentChildrenMap,
10 | })
11 | ).to.equal(new Set(['d2', 'c2', 'b3', 'a3', 'a2']))
12 | })
13 |
14 | it('should get leaves by nodeId', function() {
15 | expect(
16 | getLeaves({
17 | nodeId: 'b1',
18 | parentChildrenMap: this.parentChildrenMap,
19 | })
20 | ).to.equal(new Set(['d2', 'c2']))
21 | })
22 | })
23 |
--------------------------------------------------------------------------------
/getSortedParentChildrenMap.spec.js:
--------------------------------------------------------------------------------
1 | var { expect } = require('chai')
2 | const { Map, List } = require('immutable')
3 | const getSortedParentChildrenMap = require('./getSortedParentChildrenMap')
4 |
5 | describe('getSortedParentChildrenMap()', function() {
6 | it('should get the sorted parent-children map', function() {
7 | expect(
8 | getSortedParentChildrenMap(this.parentChildrenMap, (a, b) => a.localeCompare(b))
9 | ).to.equal(
10 | new Map({
11 | a0: new List(['a1', 'b1']),
12 | a1: new List(['a2', 'b2']),
13 | b1: new List(['c2', 'd2']),
14 | b2: new List(['a3', 'b3']),
15 | })
16 | )
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/getLeaves.js:
--------------------------------------------------------------------------------
1 | const { Set } = require('immutable')
2 | const isEmpty = require('lodash/isEmpty')
3 | const getRoots = require('./getRoots')
4 |
5 | module.exports = function getLeaves({ parentChildrenMap, nodeId }) {
6 | const rootNodeIds = !isEmpty(nodeId) ? new Set([nodeId]) : getRoots(parentChildrenMap)
7 | return rootNodeIds.reduce(
8 | (acc, rootNodeId) => acc.concat(getLeavesR({ parentChildrenMap }, acc, rootNodeId)),
9 | new Set()
10 | )
11 | }
12 |
13 | function getLeavesR({ parentChildrenMap }, acc, nodeId) {
14 | if (!parentChildrenMap.has(nodeId)) return acc.add(nodeId)
15 | return parentChildrenMap
16 | .get(nodeId)
17 | .reduce(getLeavesR.bind(undefined, { parentChildrenMap }), acc)
18 | }
19 |
--------------------------------------------------------------------------------
/traverseDepthFirst.js:
--------------------------------------------------------------------------------
1 | const noop = require('lodash/noop')
2 |
3 | module.exports = function traverseDepthFirst(
4 | { depth = Number.MAX_SAFE_INTEGER, nodeId, parentChildrenMap },
5 | iteratee = noop
6 | ) {
7 | return traverseDepthFirstR(
8 | {
9 | depth,
10 | iteratee,
11 | parentChildrenMap,
12 | },
13 | nodeId
14 | )
15 | }
16 |
17 | function traverseDepthFirstR({ depth, iteratee, parentChildrenMap }, nodeId) {
18 | iteratee(nodeId)
19 | if (depth === 0 || !parentChildrenMap.has(nodeId)) return
20 | return parentChildrenMap.get(nodeId).forEach(
21 | traverseDepthFirstR.bind(undefined, {
22 | depth: depth - 1,
23 | iteratee,
24 | parentChildrenMap,
25 | })
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/getDescendents.spec.js:
--------------------------------------------------------------------------------
1 | var { expect } = require('chai')
2 | const { Set } = require('immutable')
3 | const getDescendents = require('./getDescendents')
4 |
5 | describe('getDescendents()', function() {
6 | it('should get the children', function() {
7 | expect(
8 | getDescendents({
9 | nodeId: 'a0',
10 | parentChildrenMap: this.parentChildrenMap,
11 | })
12 | ).to.equal(new Set(['a1', 'a2', 'b2', 'a3', 'b3', 'b1', 'c2', 'd2']))
13 | })
14 |
15 | it('should get the children to depth', function() {
16 | expect(
17 | getDescendents({
18 | depth: 1,
19 | nodeId: 'a0',
20 | parentChildrenMap: this.parentChildrenMap,
21 | })
22 | ).to.equal(new Set(['a1', 'b1']))
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/getNode.spec.js:
--------------------------------------------------------------------------------
1 | const { mount } = require('enzyme')
2 | const { List, Set } = require('immutable')
3 | const noop = require('lodash/noop')
4 | const React = require('react')
5 | const getNode = require('./getNode')
6 |
7 | describe('getNode()', function() {
8 | let options
9 |
10 | beforeEach(function() {
11 | options = {
12 | ancestors: new List([]),
13 | descendents: new Set([]),
14 | descenders: new List([]),
15 | disabledDescendents: new Set([]),
16 | hiddenDescendents: new Set([]),
17 | isDisabled: false,
18 | isSelected: false,
19 | isUnselectable: false,
20 | onEvent: noop,
21 | selectedDescendents: new Set([]),
22 | style: {},
23 | }
24 | })
25 |
26 | it('should get the node', function() {
27 | const Node = getNode(() =>
)
28 | mount()
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/test.js:
--------------------------------------------------------------------------------
1 | const chai = require('chai')
2 | const chaiImmutable = require('chai-immutable')
3 | const { matchSnapshot } = require('chai-karma-snapshot')
4 | const enzyme = require('enzyme')
5 | const Adapter = require('enzyme-adapter-react-16')
6 | const { Map } = require('immutable')
7 | const getParentChildrenMap = require('./getParentChildrenMap')
8 | const testsContext = require.context('.', false, /\.spec\.js$/)
9 | testsContext.keys().forEach(testsContext)
10 |
11 | enzyme.configure({ adapter: new Adapter() })
12 | chai.use(chaiImmutable)
13 | chai.use(matchSnapshot)
14 | chai.config.truncateThreshold = 0
15 |
16 | beforeEach(function() {
17 | // a0
18 | // ├── a1
19 | // │ ├── a2
20 | // │ └── b2
21 | // │ ├── a3
22 | // │ └── b3
23 | // └── b1
24 | // ├── c2
25 | // └── d2
26 | this.childParentMap = new Map({
27 | a0: undefined,
28 | a1: 'a0',
29 | b1: 'a0',
30 | a2: 'a1',
31 | b2: 'a1',
32 | c2: 'b1',
33 | d2: 'b1',
34 | a3: 'b2',
35 | b3: 'b2',
36 | })
37 | this.parentChildrenMap = getParentChildrenMap(this.childParentMap)
38 | })
39 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-tree-list
2 |
3 | A higher-order React Component for developing tree list UIs. The API is composable and extensible.
4 | Renders are performant up to 1000s of nodes.
5 |
6 | ## Demo
7 |
8 | :point_right: [christophercliff.com/react-tree-list/](https://christophercliff.com/react-tree-list/)
9 |
10 | ## Usage
11 |
12 | ```jsx
13 | const { Map } = require('immutable')
14 | const getTree = require('react-tree-list/getTree')
15 | const getNode = require('react-tree-list/getNode')
16 |
17 | const Tree = getTree(getNode(({ nodeId, onEvent }) => {nodeId}
))
18 | class MyTree extends React.Component {
19 | render() {
20 | return (
21 | console.log(`clicked: ${nodeId}`)}
30 | />
31 | )
32 | }
33 | }
34 | ```
35 |
36 | ## Example
37 |
38 | See https://github.com/christophercliff/react-tree-list/tree/master/example. Run locally with `make
39 | webd`.
40 |
--------------------------------------------------------------------------------
/traverseDepthFirst.spec.js:
--------------------------------------------------------------------------------
1 | var { expect } = require('chai')
2 | const { List } = require('immutable')
3 | const traverseDepthFirst = require('./traverseDepthFirst')
4 |
5 | describe('traverseDepthFirst()', function() {
6 | it('should traverse depth first', function() {
7 | expect(
8 | new List().withMutations(mutable =>
9 | traverseDepthFirst(
10 | {
11 | nodeId: 'a0',
12 | parentChildrenMap: this.parentChildrenMap,
13 | },
14 | nodeId => mutable.push(nodeId)
15 | )
16 | )
17 | ).to.equal(new List(['a0', 'b1', 'd2', 'c2', 'a1', 'b2', 'b3', 'a3', 'a2']))
18 | })
19 |
20 | it('should traverse depth first to depth', function() {
21 | expect(
22 | new List().withMutations(mutable =>
23 | traverseDepthFirst(
24 | {
25 | depth: 2,
26 | nodeId: 'a0',
27 | parentChildrenMap: this.parentChildrenMap,
28 | },
29 | nodeId => mutable.push(nodeId)
30 | )
31 | )
32 | ).to.equal(new List(['a0', 'b1', 'd2', 'c2', 'a1', 'b2', 'a2']))
33 | })
34 | })
35 |
--------------------------------------------------------------------------------
/getTree.spec.js:
--------------------------------------------------------------------------------
1 | const { expect } = require('chai')
2 | const { mount } = require('enzyme')
3 | const { Map, Set } = require('immutable')
4 | const React = require('react')
5 | const getNode = require('./getNode')
6 | const getTree = require('./getTree')
7 |
8 | describe('getTree()', function() {
9 | const Node = getNode(() => )
10 | let options
11 |
12 | beforeEach(function() {
13 | options = {
14 | childParentMap: this.childParentMap,
15 | disabledNodes: new Set(),
16 | hiddenNodes: new Set(),
17 | nodePropsMap: new Map({
18 | a0: new Map({ name: 'A0' }),
19 | a1: new Map({ name: 'A1' }),
20 | b1: new Map({ name: 'B1' }),
21 | a2: new Map({ name: 'A2' }),
22 | b2: new Map({ name: 'B2' }),
23 | c2: new Map({ name: 'C2' }),
24 | d2: new Map({ name: 'D2' }),
25 | a3: new Map({ name: 'A3' }),
26 | b3: new Map({ name: 'B3' }),
27 | }),
28 | selectedNodes: new Set(),
29 | unselectableNodes: new Set(),
30 | }
31 | })
32 |
33 | it('should pass on the node props', function() {
34 | const Tree = getTree(Node)
35 | const wrapper = mount()
36 | const firstNodeWrapper = wrapper.find(Node).first()
37 | expect(firstNodeWrapper.prop('name')).to.equal('A0')
38 | })
39 | })
40 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | const entry = 'test.js'
2 | const snapshots = '**/__snapshots__/**/*.md'
3 |
4 | module.exports = function(config) {
5 | config.set({
6 | browsers: ['Chrome'],
7 | client: {
8 | mocha: { reporter: 'html' },
9 | },
10 | colors: true,
11 | files: [entry, snapshots],
12 | frameworks: ['mocha', 'snapshot', 'mocha-snapshot'],
13 | logLevel: config.LOG_INFO,
14 | port: 9876,
15 | preprocessors: {
16 | [snapshots]: ['snapshot'],
17 | [entry]: ['webpack', 'sourcemap'],
18 | },
19 | reporters: ['progress'],
20 | singleRun: false,
21 | snapshot: { update: false },
22 | webpack: {
23 | devtool: 'inline-source-map',
24 | module: {
25 | rules: [
26 | {
27 | exclude: /node_modules/,
28 | test: /\.js$/,
29 | use: {
30 | loader: 'babel-loader',
31 | options: {
32 | presets: ['es2015'],
33 | plugins: [
34 | 'transform-class-properties',
35 | 'transform-object-rest-spread',
36 | 'transform-react-jsx',
37 | ],
38 | },
39 | },
40 | },
41 | ],
42 | },
43 | },
44 | })
45 | }
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-tree-list",
3 | "version": "1.0.0",
4 | "dependencies": {
5 | "immutable": "^3.8.2",
6 | "lodash": "^4.17.4",
7 | "prop-types": "^15.6.0",
8 | "react": "^16.0.0",
9 | "react-immutable-proptypes": "^2.1.0",
10 | "react-virtualized": "^9.12.0"
11 | },
12 | "devDependencies": {
13 | "babel-core": "^6.26.0",
14 | "babel-eslint": "^8.0.2",
15 | "babel-loader": "^7.1.2",
16 | "babel-plugin-transform-class-properties": "^6.24.1",
17 | "babel-plugin-transform-object-rest-spread": "^6.26.0",
18 | "babel-plugin-transform-react-jsx": "^6.24.1",
19 | "babel-preset-es2015": "^6.24.1",
20 | "chai": "^4.1.2",
21 | "chai-immutable": "^1.6.0",
22 | "chai-karma-snapshot": "^0.6.0",
23 | "clean-webpack-plugin": "^0.1.17",
24 | "enzyme": "^3.2.0",
25 | "enzyme-adapter-react-16": "^1.1.0",
26 | "eslint": "^4.11.0",
27 | "eslint-config-prettier": "^2.8.0",
28 | "eslint-plugin-prettier": "^2.3.1",
29 | "eslint-plugin-react": "^7.5.1",
30 | "google-fonts-webpack-plugin": "^0.4.3",
31 | "html-webpack-plugin": "^2.30.1",
32 | "karma": "^1.7.1",
33 | "karma-chrome-launcher": "^2.2.0",
34 | "karma-mocha": "^1.3.0",
35 | "karma-mocha-snapshot": "^0.2.1",
36 | "karma-snapshot": "^0.5.1",
37 | "karma-sourcemap-loader": "^0.3.7",
38 | "karma-webpack": "^2.0.5",
39 | "mocha": "^4.0.1",
40 | "prettier": "^1.8.2",
41 | "react-dom": "^16.0.0",
42 | "webpack": "^3.8.1",
43 | "webpack-dev-server": "2.7.1"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/example/webpack.config.js:
--------------------------------------------------------------------------------
1 | const CleanWebpackPlugin = require('clean-webpack-plugin')
2 | const GoogleFontsWebpackPlugin = require('google-fonts-webpack-plugin')
3 | const HtmlWebpackPlugin = require('html-webpack-plugin')
4 | const { resolve } = require('path')
5 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
6 | const { DefinePlugin } = require('webpack')
7 |
8 | const outputDir = 'dist'
9 |
10 | module.exports = {
11 | context: __dirname,
12 | devServer: { contentBase: outputDir },
13 | devtool: 'inline-source-map',
14 | entry: './entry.js',
15 | module: {
16 | rules: [
17 | {
18 | exclude: /node_modules/,
19 | test: /\.js$/,
20 | use: {
21 | loader: 'babel-loader',
22 | options: {
23 | presets: ['es2015'],
24 | plugins: [
25 | 'transform-class-properties',
26 | 'transform-object-rest-spread',
27 | 'transform-react-jsx',
28 | ],
29 | },
30 | },
31 | },
32 | ],
33 | },
34 | output: {
35 | filename: 'app-[hash].js',
36 | path: resolve(__dirname, outputDir),
37 | },
38 | plugins: [
39 | new DefinePlugin({
40 | 'process.env': {
41 | NODE_ENV: JSON.stringify('production'),
42 | },
43 | }),
44 | // new UglifyJsPlugin(),
45 | new CleanWebpackPlugin([outputDir]),
46 | new HtmlWebpackPlugin({
47 | title: 'react-tree-list',
48 | }),
49 | new GoogleFontsWebpackPlugin({
50 | fonts: [
51 | {
52 | family: 'Roboto Mono',
53 | variants: ['500'],
54 | },
55 | {
56 | family: 'Source Sans Pro',
57 | variants: ['600'],
58 | },
59 | ],
60 | formats: ['woff'],
61 | }),
62 | ],
63 | }
64 |
--------------------------------------------------------------------------------
/getNode.js:
--------------------------------------------------------------------------------
1 | const isEqual = require('lodash/isEqual')
2 | const PropTypes = require('prop-types')
3 | const React = require('react')
4 | const ImmutablePropTypes = require('react-immutable-proptypes')
5 |
6 | module.exports = function getNode(WrappedNode) {
7 | return class Node extends React.Component {
8 | static displayName = 'Node'
9 | static propTypes = {
10 | ancestors: ImmutablePropTypes.list.isRequired,
11 | descendents: ImmutablePropTypes.set.isRequired,
12 | descenders: ImmutablePropTypes.list.isRequired,
13 | disabledDescendents: ImmutablePropTypes.set.isRequired,
14 | hiddenDescendents: ImmutablePropTypes.set.isRequired,
15 | isDisabled: PropTypes.bool.isRequired,
16 | isSelected: PropTypes.bool.isRequired,
17 | isUnselectable: PropTypes.bool.isRequired,
18 | onEvent: PropTypes.func.isRequired,
19 | selectedDescendents: ImmutablePropTypes.set.isRequired,
20 | style: PropTypes.object,
21 | }
22 |
23 | onEvent = options => {
24 | this.props.onEvent({
25 | ...this.props,
26 | ...options,
27 | })
28 | }
29 |
30 | shouldComponentUpdate(nextProps) {
31 | return (
32 | nextProps.isDisabled !== this.props.isDisabled ||
33 | nextProps.isSelected !== this.props.isSelected ||
34 | nextProps.isUnselectable !== this.props.isUnselectable ||
35 | !nextProps.ancestors.equals(this.props.ancestors) ||
36 | !nextProps.descendents.equals(this.props.descendents) ||
37 | !nextProps.descenders.equals(this.props.descenders) ||
38 | !nextProps.disabledDescendents.equals(this.props.disabledDescendents) ||
39 | !nextProps.hiddenDescendents.equals(this.props.hiddenDescendents) ||
40 | !nextProps.selectedDescendents.equals(this.props.selectedDescendents) ||
41 | !isEqual(nextProps.style, this.props.style)
42 | )
43 | }
44 |
45 | render() {
46 | return
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/example/getExampleTree.js:
--------------------------------------------------------------------------------
1 | const { Set } = require('immutable')
2 | const get = require('lodash/get')
3 | const noop = require('lodash/noop')
4 | const PropTypes = require('prop-types')
5 | const React = require('react')
6 | const ImmutablePropTypes = require('react-immutable-proptypes')
7 | const getDescendents = require('../getDescendents')
8 | const getTree = require('../getTree')
9 |
10 | module.exports = function getExampleTree(Node) {
11 | const Tree = getTree(Node)
12 | return class ExampleTree extends React.PureComponent {
13 | static defaultProps = {
14 | hiddenNodes: new Set(),
15 | selectedNodes: new Set(),
16 | onChange: noop,
17 | }
18 | static displayName = 'ExampleTree'
19 | static propTypes = {
20 | childParentMap: ImmutablePropTypes.map.isRequired,
21 | hiddenNodes: ImmutablePropTypes.set,
22 | selectedNodes: ImmutablePropTypes.set,
23 | onChange: PropTypes.func,
24 | }
25 |
26 | onEvent = ({ type, ...options }) => {
27 | get(this, type)(options)
28 | }
29 |
30 | selectNode = ({ ancestors, descendents, nodeId, parentChildrenMap }) => {
31 | this.props.onChange({
32 | selectedNodes: this.props.selectedNodes.withMutations(mutable => {
33 | mutable.add(nodeId).union(descendents)
34 | ancestors.forEach(
35 | nodeId =>
36 | parentChildrenMap.get(nodeId).every(_nodeId => mutable.has(_nodeId)) &&
37 | mutable.add(nodeId)
38 | )
39 | }),
40 | })
41 | }
42 |
43 | deselectNode = ({ ancestors, descendents, nodeId, parentChildrenMap }) => {
44 | this.props.onChange({
45 | selectedNodes: this.props.selectedNodes.withMutations(mutable => {
46 | mutable.subtract(descendents).delete(nodeId)
47 | ancestors.forEach(
48 | nodeId =>
49 | !parentChildrenMap.get(nodeId).every(_nodeId => mutable.has(_nodeId)) &&
50 | mutable.delete(nodeId)
51 | )
52 | }),
53 | })
54 | }
55 |
56 | hideDescendents = ({ descendents }) => {
57 | this.props.onChange({ hiddenNodes: this.props.hiddenNodes.union(descendents) })
58 | }
59 |
60 | showDescendents = ({ nodeId, parentChildrenMap }) => {
61 | this.props.onChange({
62 | hiddenNodes: this.props.hiddenNodes.subtract(
63 | getDescendents({ depth: 1, nodeId, parentChildrenMap })
64 | ),
65 | })
66 | }
67 |
68 | render() {
69 | return
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/getRenderList.js:
--------------------------------------------------------------------------------
1 | const { fromJS, List, OrderedMap, Set } = require('immutable')
2 |
3 | module.exports = function getRenderList({
4 | disabledNodes = new Set(),
5 | hiddenNodes = new Set(),
6 | roots,
7 | selectedNodes = new Set(),
8 | sortedParentChildrenMap,
9 | }) {
10 | return roots.reduce((acc, rootNodeId) => {
11 | return acc.concat(
12 | getRenderListR(
13 | {
14 | disabledNodes,
15 | hiddenNodes,
16 | selectedNodes,
17 | sortedParentChildrenMap,
18 | },
19 | new OrderedMap(),
20 | rootNodeId,
21 | 0,
22 | new List([rootNodeId])
23 | ).filter((node, nodeId) => !hiddenNodes.has(nodeId))
24 | )
25 | }, new List())
26 | }
27 |
28 | function getRenderListR(
29 | {
30 | disabledNodes,
31 | hiddenNodes,
32 | parentId,
33 | parentIsLasts = new List(),
34 | selectedNodes,
35 | sortedParentChildrenMap,
36 | },
37 | acc,
38 | nodeId,
39 | i,
40 | list
41 | ) {
42 | const isLastChild = i === list.size - 1
43 | parentIsLasts = parentIsLasts.push(isLastChild)
44 | acc = acc.withMutations(mutable => {
45 | mutable.set(
46 | nodeId,
47 | fromJS({
48 | ancestors: parentId
49 | ? mutable.getIn([parentId, 'ancestors']).unshift(parentId)
50 | : new List(),
51 | descendents: new Set(),
52 | descenders: parentIsLasts.shift().map((parentIsLast, _i, _list) => {
53 | return _i === _list.size - 1 ? true : !parentIsLast
54 | }),
55 | disabledDescendents: new Set(),
56 | hiddenDescendents: new Set(),
57 | isLastChild,
58 | nodeId,
59 | selectedDescendents: new Set(),
60 | })
61 | )
62 | if (parentId) {
63 | mutable.updateIn([parentId, 'descendents'], set => set.add(nodeId))
64 | if (disabledNodes.has(nodeId))
65 | mutable.updateIn([parentId, 'disabledDescendents'], set => set.add(nodeId))
66 | if (hiddenNodes.has(nodeId))
67 | mutable.updateIn([parentId, 'hiddenDescendents'], set => set.add(nodeId))
68 | if (selectedNodes.has(nodeId))
69 | mutable.updateIn([parentId, 'selectedDescendents'], set => set.add(nodeId))
70 | }
71 | })
72 | if (!sortedParentChildrenMap.get(nodeId)) return acc
73 | acc = sortedParentChildrenMap.get(nodeId).reduce(
74 | getRenderListR.bind(undefined, {
75 | disabledNodes,
76 | hiddenNodes,
77 | parentId: nodeId,
78 | parentIsLasts,
79 | selectedNodes,
80 | sortedParentChildrenMap,
81 | }),
82 | acc
83 | )
84 | return acc.withMutations(mutable => {
85 | if (parentId) {
86 | mutable.updateIn([parentId, 'descendents'], set =>
87 | set.union(acc.getIn([nodeId, 'descendents']))
88 | )
89 | mutable.updateIn([parentId, 'disabledDescendents'], set =>
90 | set.union(acc.getIn([nodeId, 'disabledDescendents']))
91 | )
92 | mutable.updateIn([parentId, 'hiddenDescendents'], set =>
93 | set.union(acc.getIn([nodeId, 'hiddenDescendents']))
94 | )
95 | mutable.updateIn([parentId, 'selectedDescendents'], set =>
96 | set.union(acc.getIn([nodeId, 'selectedDescendents']))
97 | )
98 | }
99 | })
100 | }
101 |
--------------------------------------------------------------------------------
/example/HtmlTree.js:
--------------------------------------------------------------------------------
1 | const extend = require('lodash/extend')
2 | const omit = require('lodash/omit')
3 | const PropTypes = require('prop-types')
4 | const React = require('react')
5 | const getExampleTree = require('./getExampleTree')
6 | const getNode = require('../getNode')
7 |
8 | class Checkbox extends React.Component {
9 | static propTypes = {
10 | indeterminate: PropTypes.bool,
11 | }
12 |
13 | componentDidMount() {
14 | this.el.indeterminate = this.props.indeterminate
15 | }
16 |
17 | componentDidUpdate(prevProps) {
18 | if (prevProps.indeterminate !== this.props.indeterminate) {
19 | this.el.indeterminate = this.props.indeterminate
20 | }
21 | }
22 |
23 | render() {
24 | return (
25 | (this.el = el)}
28 | type="checkbox"
29 | />
30 | )
31 | }
32 | }
33 |
34 | const Tree = getExampleTree(
35 | getNode(
36 | ({
37 | ancestors,
38 | descendents,
39 | hiddenDescendents,
40 | isSelected,
41 | nodeId,
42 | onEvent,
43 | selectedDescendents,
44 | style,
45 | }) => {
46 | const hasVisibleDescendents = !hiddenDescendents.equals(descendents)
47 | return (
48 |
57 |
72 |
76 | onEvent({
77 | type: isSelected ? 'deselectNode' : 'selectNode',
78 | })
79 | }
80 | style={{
81 | display: 'inline-block',
82 | fontSize: 'inherit',
83 | marginLeft: '0.50em',
84 | }}
85 | />
86 |
92 | {nodeId}
93 |
94 |
95 | )
96 | }
97 | )
98 | )
99 |
100 | module.exports = class MyTree extends React.PureComponent {
101 | static displayName = 'HtmlTree'
102 |
103 | render() {
104 | return (
105 |
122 |
123 |
124 | )
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/__snapshots__/getRenderList().md:
--------------------------------------------------------------------------------
1 | # `getRenderList()`
2 |
3 | #### `should get the render list`
4 |
5 | ```
6 | Immutable.List [
7 | Immutable.Map {
8 | ancestors: Immutable.List [
9 | ],
10 | descendents: Immutable.Set [
11 | "a1",
12 | "a2",
13 | "b2",
14 | "a3",
15 | "b3",
16 | "b1",
17 | "c2",
18 | "d2",
19 | ],
20 | descenders: Immutable.List [
21 | ],
22 | disabledDescendents: Immutable.Set [
23 | ],
24 | hiddenDescendents: Immutable.Set [
25 | ],
26 | isLastChild: true,
27 | nodeId: "a0",
28 | selectedDescendents: Immutable.Set [
29 | ],
30 | },
31 | Immutable.Map {
32 | ancestors: Immutable.List [
33 | "a0",
34 | ],
35 | descendents: Immutable.Set [
36 | "a2",
37 | "b2",
38 | "a3",
39 | "b3",
40 | ],
41 | descenders: Immutable.List [
42 | true,
43 | ],
44 | disabledDescendents: Immutable.Set [
45 | ],
46 | hiddenDescendents: Immutable.Set [
47 | ],
48 | isLastChild: false,
49 | nodeId: "a1",
50 | selectedDescendents: Immutable.Set [
51 | ],
52 | },
53 | Immutable.Map {
54 | ancestors: Immutable.List [
55 | "a1",
56 | "a0",
57 | ],
58 | descendents: Immutable.Set [
59 | ],
60 | descenders: Immutable.List [
61 | true,
62 | true,
63 | ],
64 | disabledDescendents: Immutable.Set [
65 | ],
66 | hiddenDescendents: Immutable.Set [
67 | ],
68 | isLastChild: false,
69 | nodeId: "a2",
70 | selectedDescendents: Immutable.Set [
71 | ],
72 | },
73 | Immutable.Map {
74 | ancestors: Immutable.List [
75 | "a1",
76 | "a0",
77 | ],
78 | descendents: Immutable.Set [
79 | "a3",
80 | "b3",
81 | ],
82 | descenders: Immutable.List [
83 | true,
84 | true,
85 | ],
86 | disabledDescendents: Immutable.Set [
87 | ],
88 | hiddenDescendents: Immutable.Set [
89 | ],
90 | isLastChild: true,
91 | nodeId: "b2",
92 | selectedDescendents: Immutable.Set [
93 | ],
94 | },
95 | Immutable.Map {
96 | ancestors: Immutable.List [
97 | "b2",
98 | "a1",
99 | "a0",
100 | ],
101 | descendents: Immutable.Set [
102 | ],
103 | descenders: Immutable.List [
104 | true,
105 | false,
106 | true,
107 | ],
108 | disabledDescendents: Immutable.Set [
109 | ],
110 | hiddenDescendents: Immutable.Set [
111 | ],
112 | isLastChild: false,
113 | nodeId: "a3",
114 | selectedDescendents: Immutable.Set [
115 | ],
116 | },
117 | Immutable.Map {
118 | ancestors: Immutable.List [
119 | "b2",
120 | "a1",
121 | "a0",
122 | ],
123 | descendents: Immutable.Set [
124 | ],
125 | descenders: Immutable.List [
126 | true,
127 | false,
128 | true,
129 | ],
130 | disabledDescendents: Immutable.Set [
131 | ],
132 | hiddenDescendents: Immutable.Set [
133 | ],
134 | isLastChild: true,
135 | nodeId: "b3",
136 | selectedDescendents: Immutable.Set [
137 | ],
138 | },
139 | Immutable.Map {
140 | ancestors: Immutable.List [
141 | "a0",
142 | ],
143 | descendents: Immutable.Set [
144 | "c2",
145 | "d2",
146 | ],
147 | descenders: Immutable.List [
148 | true,
149 | ],
150 | disabledDescendents: Immutable.Set [
151 | ],
152 | hiddenDescendents: Immutable.Set [
153 | ],
154 | isLastChild: true,
155 | nodeId: "b1",
156 | selectedDescendents: Immutable.Set [
157 | ],
158 | },
159 | Immutable.Map {
160 | ancestors: Immutable.List [
161 | "b1",
162 | "a0",
163 | ],
164 | descendents: Immutable.Set [
165 | ],
166 | descenders: Immutable.List [
167 | false,
168 | true,
169 | ],
170 | disabledDescendents: Immutable.Set [
171 | ],
172 | hiddenDescendents: Immutable.Set [
173 | ],
174 | isLastChild: false,
175 | nodeId: "c2",
176 | selectedDescendents: Immutable.Set [
177 | ],
178 | },
179 | Immutable.Map {
180 | ancestors: Immutable.List [
181 | "b1",
182 | "a0",
183 | ],
184 | descendents: Immutable.Set [
185 | ],
186 | descenders: Immutable.List [
187 | false,
188 | true,
189 | ],
190 | disabledDescendents: Immutable.Set [
191 | ],
192 | hiddenDescendents: Immutable.Set [
193 | ],
194 | isLastChild: true,
195 | nodeId: "d2",
196 | selectedDescendents: Immutable.Set [
197 | ],
198 | },
199 | ]
200 | ```
201 |
202 |
--------------------------------------------------------------------------------
/example/App.js:
--------------------------------------------------------------------------------
1 | const { Map, Set } = require('immutable')
2 | const get = require('lodash/get')
3 | const isEmpty = require('lodash/isEmpty')
4 | const keyBy = require('lodash/keyBy')
5 | const map = require('lodash/map')
6 | const random = require('lodash/random')
7 | const range = require('lodash/range')
8 | const shuffle = require('lodash/shuffle')
9 | const React = require('react')
10 | const HtmlTree = require('./HtmlTree')
11 | const TextTree = require('./TextTree')
12 |
13 | const nodeCounts = [50, 100, 500, 1000, 5000]
14 | const modes = keyBy(['text', 'html'])
15 |
16 | module.exports = class App extends React.PureComponent {
17 | static displayName = 'App'
18 | constructor(props) {
19 | super(props)
20 | const nodeCount = get(nodeCounts, 3)
21 | this.state = {
22 | childParentMap: getRandomChildParentMap(nodeCount),
23 | hiddenNodes: new Set(),
24 | mode: modes.text,
25 | nodeCount,
26 | selectedNodes: new Set(),
27 | }
28 | }
29 |
30 | onChangeMode = mode => {
31 | this.setState({ mode })
32 | }
33 |
34 | onChangeNodeCount = nodeCount => {
35 | this.setState({
36 | childParentMap: getRandomChildParentMap(nodeCount),
37 | hiddenNodes: new Set(),
38 | nodeCount,
39 | selectedNodes: new Set(),
40 | })
41 | }
42 |
43 | onChangeTree = ({ hiddenNodes, selectedNodes }) => {
44 | this.setState({
45 | hiddenNodes: hiddenNodes ? hiddenNodes : this.state.hiddenNodes,
46 | selectedNodes: selectedNodes ? selectedNodes : this.state.selectedNodes,
47 | })
48 | }
49 |
50 | render() {
51 | return (
52 |
53 | {this.state.mode === modes.text ? (
54 |
60 | ) : (
61 |
67 | )}
68 |
78 | {map(modes, mode => (
79 |
87 | ))}
88 | {' '}
89 | {map(nodeCounts, nodeCount => (
90 |
98 | ))}{' '}
99 |
103 | GitHub
104 |
105 |
106 |
107 | )
108 | }
109 | }
110 |
111 | function getRandomChildParentMap(n) {
112 | const dst = shuffle(range(n))
113 | const src = []
114 | let childParentMap = new Map()
115 | let a
116 | let b
117 | src.push(dst.pop())
118 | childParentMap = childParentMap.set(src[0].toString())
119 | while (!isEmpty(dst)) {
120 | a = src[random(src.length - 1)]
121 | b = dst.pop()
122 | childParentMap = childParentMap.set(b.toString(), a.toString())
123 | src.push(b)
124 | }
125 | return childParentMap
126 | }
127 |
--------------------------------------------------------------------------------
/example/TextTree.js:
--------------------------------------------------------------------------------
1 | const React = require('react')
2 | const getNode = require('../getNode')
3 | const getExampleTree = require('./getExampleTree')
4 |
5 | const colorPurple = '#c5a5c5'
6 | const colorBlue = '#79b6f2'
7 | const colorGreen = '#5fb3b3'
8 | const colorWhite = '#fff'
9 | const a = '├── '
10 | const b = '└── '
11 | const c = '│ '
12 | const d = ' '
13 | const Tree = getExampleTree(
14 | getNode(
15 | ({
16 | descendents,
17 | descenders,
18 | hiddenDescendents,
19 | isLastChild,
20 | isSelected,
21 | nodeId,
22 | onEvent,
23 | selectedDescendents,
24 | style,
25 | }) => {
26 | const hasVisibleDescendents = !hiddenDescendents.equals(descendents)
27 | return (
28 |
29 |
30 | {descenders
31 | .map((hasDescender, i, list) => (
32 |
33 | {(() => {
34 | if (hasDescender) {
35 | if (i === list.size - 1) {
36 | if (isLastChild) return b
37 | return a
38 | }
39 | return c
40 | }
41 | return d
42 | })()}
43 |
44 | ))
45 | .valueSeq()}
46 |
47 |
53 | {!descendents.isEmpty() ? (
54 |
55 |
57 | onEvent({
58 | type: hasVisibleDescendents
59 | ? 'hideDescendents'
60 | : 'showDescendents',
61 | })
62 | }
63 | style={{
64 | color: colorBlue,
65 | cursor: 'pointer',
66 | }}
67 | >
68 | {hasVisibleDescendents ? '-' : '+'}
69 | {' '}
70 |
71 | ) : (
72 | {'- '}
73 | )}
74 |
76 | onEvent({
77 | type: isSelected ? 'deselectNode' : 'selectNode',
78 | })
79 | }
80 | style={{
81 | color: colorGreen,
82 | cursor: 'pointer',
83 | }}
84 | >
85 | [{(() => {
86 | if (isSelected) return 'x'
87 | if (!selectedDescendents.isEmpty()) return '-'
88 | return ' '
89 | })()}]{' '}
90 |
91 | {nodeId}
92 |
93 |
94 | )
95 | }
96 | )
97 | )
98 |
99 | module.exports = class MyTree extends React.PureComponent {
100 | static displayName = 'TextTree'
101 |
102 | render() {
103 | return (
104 |
120 |
121 |
122 | )
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/getTree.js:
--------------------------------------------------------------------------------
1 | const { Map, Set } = require('immutable')
2 | const noop = require('lodash/noop')
3 | const PropTypes = require('prop-types')
4 | const React = require('react')
5 | const ImmutablePropTypes = require('react-immutable-proptypes')
6 | const { AutoSizer, CellMeasurer, CellMeasurerCache, List } = require('react-virtualized')
7 | const getParentChildrenMap = require('./getParentChildrenMap')
8 | const getRenderList = require('./getRenderList')
9 | const getRoots = require('./getRoots')
10 | const getSortedParentChildrenMap = require('./getSortedParentChildrenMap')
11 |
12 | module.exports = function getTree(Node) {
13 | return class Tree extends React.PureComponent {
14 | static defaultProps = {
15 | disabledNodes: new Set(),
16 | hiddenNodes: new Set(),
17 | nodePropsMap: new Map(),
18 | onEvent: noop,
19 | selectedNodes: new Set(),
20 | unselectableNodes: new Set(),
21 | }
22 | static displayName = 'Tree'
23 | static propTypes = {
24 | childParentMap: ImmutablePropTypes.map.isRequired,
25 | comparator: PropTypes.func,
26 | disabledNodes: ImmutablePropTypes.set,
27 | hiddenNodes: ImmutablePropTypes.set,
28 | nodePropsMap: ImmutablePropTypes.map,
29 | onEvent: PropTypes.func,
30 | selectedNodes: ImmutablePropTypes.set,
31 | unselectableNodes: ImmutablePropTypes.set,
32 | }
33 |
34 | constructor(props) {
35 | super(props)
36 | const parentChildrenMap = getParentChildrenMap(props.childParentMap)
37 | this.state = {
38 | parentChildrenMap: parentChildrenMap,
39 | roots: getRoots(parentChildrenMap),
40 | sortedParentChildrenMap: getSortedParentChildrenMap(
41 | parentChildrenMap,
42 | props.comparator
43 | ),
44 | }
45 | this.cellMeasurerCache = new CellMeasurerCache({ fixedWidth: true })
46 | }
47 |
48 | componentWillReceiveProps(nextProps) {
49 | if (nextProps.childParentMap !== this.props.childParentMap) {
50 | const parentChildrenMap = getParentChildrenMap(nextProps.childParentMap)
51 | this.setState({
52 | parentChildrenMap: parentChildrenMap,
53 | roots: getRoots(parentChildrenMap),
54 | sortedParentChildrenMap: getSortedParentChildrenMap(
55 | parentChildrenMap,
56 | nextProps.comparator
57 | ),
58 | })
59 | }
60 | }
61 |
62 | onEvent = options => {
63 | this.props.onEvent({
64 | parentChildrenMap: this.state.parentChildrenMap,
65 | ...options,
66 | })
67 | }
68 |
69 | render() {
70 | const { disabledNodes, hiddenNodes, selectedNodes } = this.props
71 | const { roots, sortedParentChildrenMap } = this.state
72 | const renderList = getRenderList({
73 | disabledNodes,
74 | hiddenNodes,
75 | roots,
76 | selectedNodes,
77 | sortedParentChildrenMap,
78 | })
79 | if (renderList.size > 100)
80 | return (
81 |
82 | {({ height, width }) => (
83 |
91 | )}
92 |
93 | )
94 | return (
95 |
96 | {renderList.map(this.renderStaticRow.bind(this, { renderList })).valueSeq()}
97 |
98 | )
99 | }
100 |
101 | renderVirtualRow({ renderList }, { index, key, parent, style }) {
102 | return (
103 |
109 | {this.renderRow({ renderList }, { index, style })}
110 |
111 | )
112 | }
113 |
114 | renderStaticRow({ renderList }, renderNode, index) {
115 | return this.renderRow({ renderList }, { index })
116 | }
117 |
118 | renderRow({ renderList }, { index, style }) {
119 | const renderNode = renderList.get(index)
120 | const nodeId = renderNode.get('nodeId')
121 | const { disabledNodes, selectedNodes, unselectableNodes } = this.props
122 | return (
123 |
140 | )
141 | }
142 | }
143 | }
144 |
--------------------------------------------------------------------------------