├── .babelrc.js
├── .eslintrc.js
├── .github
├── FUNDING.yml
└── ISSUE_TEMPLATE
│ ├── bug-report.md
│ └── feature-request.md
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── .storybook
├── main.js
└── manager.js
├── .travis.yml
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── __mocks__
├── fileMock.js
├── react-dnd-html5-backend.js
├── react-dnd-scrollzone.js
└── react-virtualized.js
├── package.json
├── rollup.config.js
├── src
├── __snapshots__
│ └── react-sortable-tree.test.js.snap
├── index.js
├── node-renderer-default.css
├── node-renderer-default.js
├── placeholder-renderer-default.css
├── placeholder-renderer-default.js
├── react-sortable-tree.css
├── react-sortable-tree.js
├── react-sortable-tree.test.js
├── tests.js
├── tree-node.css
├── tree-node.js
├── tree-placeholder.js
└── utils
│ ├── classnames.js
│ ├── default-handlers.js
│ ├── dnd-manager.js
│ ├── generic-utils.js
│ ├── generic-utils.test.js
│ ├── memoized-tree-data-utils.js
│ ├── memoized-tree-data-utils.test.js
│ ├── tree-data-utils.js
│ └── tree-data-utils.test.js
├── stories
├── __snapshots__
│ └── storyshots.test.js.snap
├── add-remove.js
├── barebones-no-context.js
├── barebones.js
├── callbacks.js
├── can-drop.js
├── childless-nodes.js
├── drag-out-to-remove.js
├── external-node.js
├── generate-node-props.js
├── generic.css
├── index.js
├── modify-nodes.js
├── only-expand-searched-node.js
├── rtl-support.js
├── search.js
├── storyshots.test.js
├── themes.js
├── touch-support.js
├── tree-data-io.js
└── tree-to-tree.js
├── test-config
├── shim.js
└── test-setup.js
└── yarn.lock
/.babelrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | [
4 | '@babel/preset-env',
5 | {
6 | modules: false,
7 | },
8 | ],
9 | '@babel/preset-react',
10 | ],
11 | env: {
12 | test: {
13 | plugins: ['@babel/plugin-transform-modules-commonjs'],
14 | },
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['eslint-config-airbnb', 'prettier', 'prettier/react'],
3 | parser: 'babel-eslint',
4 | env: {
5 | browser: true,
6 | jest: true,
7 | },
8 | rules: {
9 | 'react/destructuring-assignment': 0,
10 | 'react/jsx-filename-extension': 0,
11 | 'react/prefer-stateless-function': 0,
12 | 'react/no-did-mount-set-state': 0,
13 | 'react/sort-comp': 0,
14 | 'react/jsx-props-no-spreading': 0,
15 | 'react/prop-types': 0,
16 | 'no-shadow': 0,
17 | 'jsx-a11y/label-has-associated-control': 0,
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: wuweiweiwu
2 | open_collective: react-sortable-tree
3 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug Report
3 | about: Bugs, missing documentation, or unexpected behavior 🤔.
4 | ---
5 |
6 | # Reporting a Bug?
7 |
8 | Please include either a failing unit test or a simple reproduction. You can start by forking the [CodeSandbox example](https://codesandbox.io/s/wkxvy3z15w)
9 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Ideas and suggestions
4 | ---
5 |
6 | # Requesting a Feature?
7 |
8 | Provide as much information as possible about your requested feature. Here are a few questions you may consider answering:
9 |
10 | - What's your use case? (Tell me about your application and what problem you're trying to solve.)
11 | - What interface do you have in mind? (What new properties or methods do you think might be helpful?)
12 | - Can you point to similar functionality with any existing libraries or components? (Working demos can be helpful.)
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | package-lock.json
4 | coverage
5 |
6 | # Editor and other tmp files
7 | *.swp
8 | *.un~
9 | *.iml
10 | *.ipr
11 | *.iws
12 | *.sublime-*
13 | .idea/
14 | *.DS_Store
15 |
16 | # Build directories (Will be preserved by npm)
17 | .cache
18 | dist
19 | build
20 | style.css
21 | style.css.map
22 |
23 | # Error files
24 | yarn-error.log
25 |
26 | .vscode
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v8.11.2
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | CHANGELOG.md
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "trailingComma": "es5",
8 | "bracketSpacing": true,
9 | "jsxBracketSameLine": false,
10 | "overrides": [
11 | {
12 | "files": ".prettierrc",
13 | "options": { "parser": "json", "trailingComma": "none" }
14 | },
15 | {
16 | "files": "*.json",
17 | "options": { "trailingComma": "none" }
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/.storybook/main.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | stories: ['../stories/index.js'],
3 | };
4 |
--------------------------------------------------------------------------------
/.storybook/manager.js:
--------------------------------------------------------------------------------
1 | import { addons } from '@storybook/addons';
2 | import { create } from '@storybook/theming/create';
3 |
4 | addons.setConfig({
5 | theme: create({
6 | base: 'light',
7 | brandTitle: 'React Sortable Tree',
8 | brandUrl: 'https://github.com/frontend-collective/react-sortable-tree',
9 | gridCellSize: 12,
10 | }),
11 | });
12 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | script:
3 | - npm test -- --coverage && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js
4 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4 |
5 | ## [2.8.0](https://github.com/frontend-collective/react-sortable-tree/compare/v2.7.1...v2.8.0) (2020-08-10)
6 |
7 |
8 | ### Features
9 |
10 | * adding FUNDING.yml ([8e87804](https://github.com/frontend-collective/react-sortable-tree/commit/8e87804195fcc6cfc98ac0c8ae3a6f8511c05898))
11 | * remove current codesandbox website ([30749c7](https://github.com/frontend-collective/react-sortable-tree/commit/30749c74deba9b254c674bc0ded4fe2e6eb4cdce))
12 |
13 |
14 | ### Bug Fixes
15 |
16 | * accidentally deleted own styling ([c664ade](https://github.com/frontend-collective/react-sortable-tree/commit/c664adee1cc045a76a9f89c38b644aa996f38365))
17 | * don't prettify changelog ([8615412](https://github.com/frontend-collective/react-sortable-tree/commit/86154120b0814a72ad45b23b4a24f45f2bbac225))
18 | * open collective link ([d55561e](https://github.com/frontend-collective/react-sortable-tree/commit/d55561e91b6abc7268be261c55c95a1fac5627e9))
19 | * remove outdated links from readme ([7a07263](https://github.com/frontend-collective/react-sortable-tree/commit/7a07263719044709ea177cd7d59ed0c0d56e86d0))
20 | * scroll to search focused tree item ([#756](https://github.com/frontend-collective/react-sortable-tree/issues/756)) ([e528a4c](https://github.com/frontend-collective/react-sortable-tree/commit/e528a4c6167cf64a6c0ff43caf22be45cccb21e3))
21 | * set themes using new api ([c2c1075](https://github.com/frontend-collective/react-sortable-tree/commit/c2c1075dfab844412f375174697ab30692b6055b))
22 | * site ([95cb249](https://github.com/frontend-collective/react-sortable-tree/commit/95cb249e24fb8cab2134567f71447bd728228c1e))
23 | * website imports ([8e7f83d](https://github.com/frontend-collective/react-sortable-tree/commit/8e7f83dc483c4697edd5ae29080316cf68de248a))
24 | * website pt 2 ([6914959](https://github.com/frontend-collective/react-sortable-tree/commit/69149596c884cb28c83c17f238c7d7d186271c44))
25 |
26 |
27 | ## [2.7.1](https://github.com/frontend-collective/react-sortable-tree/compare/v2.7.0...v2.7.1) (2019-11-12)
28 |
29 |
30 |
31 |
32 | # [2.7.0](https://github.com/frontend-collective/react-sortable-tree/compare/v2.6.2...v2.7.0) (2019-10-14)
33 |
34 |
35 | ### Features
36 |
37 | * update react-dnd ([#531](https://github.com/frontend-collective/react-sortable-tree/issues/531)) ([c449524](https://github.com/frontend-collective/react-sortable-tree/commit/c449524))
38 |
39 |
40 |
41 |
42 | ## [2.6.2](https://github.com/frontend-collective/react-sortable-tree/compare/v2.6.1...v2.6.2) (2019-03-21)
43 |
44 |
45 | ### Bug Fixes
46 |
47 | * Using DragDropContextConsumer directly ([#466](https://github.com/frontend-collective/react-sortable-tree/issues/466)) ([7bc9995](https://github.com/frontend-collective/react-sortable-tree/commit/7bc9995))
48 |
49 |
50 |
51 |
52 | ## [2.6.1](https://github.com/frontend-collective/react-sortable-tree/compare/v2.6.0...v2.6.1) (2019-03-19)
53 |
54 |
55 |
56 |
57 | # [2.6.0](https://github.com/frontend-collective/react-sortable-tree/compare/v2.5.0...v2.6.0) (2018-12-11)
58 |
59 |
60 | ### Bug Fixes
61 |
62 | * Bundling patched version of react-dnd-scrollzone ([#432](https://github.com/frontend-collective/react-sortable-tree/issues/432)) ([4017a08](https://github.com/frontend-collective/react-sortable-tree/commit/4017a08))
63 |
64 |
65 |
66 |
67 | # [2.5.0](https://github.com/frontend-collective/react-sortable-tree/compare/v2.4.0...v2.5.0) (2018-12-10)
68 |
69 |
70 | ### Bug Fixes
71 |
72 | * postinstall -> prepare ([#430](https://github.com/frontend-collective/react-sortable-tree/issues/430)) ([5f94ace](https://github.com/frontend-collective/react-sortable-tree/commit/5f94ace))
73 | * rollup external dependencies ([7b8afd4](https://github.com/frontend-collective/react-sortable-tree/commit/7b8afd4))
74 |
75 |
76 |
77 |
78 | # [2.4.0](https://github.com/frontend-collective/react-sortable-tree/compare/v2.3.0...v2.4.0) (2018-12-10)
79 |
80 |
81 |
82 |
83 | # [2.3.0](https://github.com/frontend-collective/react-sortable-tree/compare/v2.2.0...v2.3.0) (2018-10-23)
84 |
85 |
86 | ### Bug Fixes
87 |
88 | * deploy storybook to main site. fix links ([599f2ed](https://github.com/frontend-collective/react-sortable-tree/commit/599f2ed))
89 | * propagate the expanded treeData to editor ([bd0df92](https://github.com/frontend-collective/react-sortable-tree/commit/bd0df92))
90 | * update links to new website ([d68c3bf](https://github.com/frontend-collective/react-sortable-tree/commit/d68c3bf))
91 | * update props link + added PRs welcome badge ([c83c2aa](https://github.com/frontend-collective/react-sortable-tree/commit/c83c2aa))
92 | * update screenshot tests ([4977cb1](https://github.com/frontend-collective/react-sortable-tree/commit/4977cb1))
93 |
94 |
95 | ### Features
96 |
97 | * add storybook for onlyExpandSearchedNode prop ([#354](https://github.com/frontend-collective/react-sortable-tree/issues/354)) ([c4a41d1](https://github.com/frontend-collective/react-sortable-tree/commit/c4a41d1))
98 |
99 |
100 |
101 |
102 | # [2.2.0](https://github.com/frontend-collective/react-sortable-tree/compare/v2.1.2...v2.2.0) (2018-06-12)
103 |
104 |
105 | ### Bug Fixes
106 |
107 | * correct link to react-virtualized props ([#349](https://github.com/frontend-collective/react-sortable-tree/issues/349)) ([46961ed](https://github.com/frontend-collective/react-sortable-tree/commit/46961ed))
108 | * remove the extra s on style.css ([#342](https://github.com/frontend-collective/react-sortable-tree/issues/342)) ([77451bc](https://github.com/frontend-collective/react-sortable-tree/commit/77451bc))
109 |
110 |
111 | ### Features
112 |
113 | * commonjs, es6, umd build supports ([#327](https://github.com/frontend-collective/react-sortable-tree/issues/327)) ([6556e4d](https://github.com/frontend-collective/react-sortable-tree/commit/6556e4d))
114 | * NEW DOCS + SITE ([#343](https://github.com/frontend-collective/react-sortable-tree/issues/343)) ([176b8c3](https://github.com/frontend-collective/react-sortable-tree/commit/176b8c3))
115 | * Only serve cjs and esm builds ([#351](https://github.com/frontend-collective/react-sortable-tree/issues/351)) ([2c01832](https://github.com/frontend-collective/react-sortable-tree/commit/2c01832))
116 | * row direction support ([#337](https://github.com/frontend-collective/react-sortable-tree/issues/337)) ([5bef44b](https://github.com/frontend-collective/react-sortable-tree/commit/5bef44b))
117 |
118 |
119 |
120 |
121 | ## [2.1.2](https://github.com/frontend-collective/react-sortable-tree/compare/v2.1.1...v2.1.2) (2018-05-23)
122 |
123 |
124 | ### Bug Fixes
125 |
126 | * prettier ([#313](https://github.com/frontend-collective/react-sortable-tree/issues/313)) ([3456076](https://github.com/frontend-collective/react-sortable-tree/commit/3456076))
127 |
128 |
129 |
130 |
131 |
132 | ## [2.1.1](https://github.com/frontend-collective/react-sortable-tree/compare/v2.1.0...v2.1.1) (2018-04-29)
133 |
134 |
135 |
136 | # [2.1.0](https://github.com/frontend-collective/react-sortable-tree/compare/v2.0.1...v2.1.0) (2018-03-04)
137 |
138 | ### Features
139 |
140 | * Added onlyExpandSearchedNodes prop ([2d57928](https://github.com/frontend-collective/react-sortable-tree/commit/2d57928)), closes [#245](https://github.com/frontend-collective/react-sortable-tree/issues/245)
141 |
142 |
143 |
144 | ## [2.0.1](https://github.com/frontend-collective/react-sortable-tree/compare/v2.0.0...v2.0.1) (2018-02-10)
145 |
146 | ### Bug Fixes
147 |
148 | * restore highlight line appearance ([2c95205](https://github.com/frontend-collective/react-sortable-tree/commit/2c95205))
149 |
150 |
151 |
152 | # [2.0.0](https://github.com/frontend-collective/react-sortable-tree/compare/v1.8.1...v2.0.0) (2018-02-10)
153 |
154 | ### BREAKING CHANGES
155 |
156 | * from v2.0.0 on, you must import the css for the
157 | component yourself, using `import 'react-sortable-tree/style.css';`.
158 | You only need to do this once in your application.
159 |
160 | * Support dropped for IE versions earlier than IE 11
161 |
162 |
163 |
164 | ## [1.8.1](https://github.com/frontend-collective/react-sortable-tree/compare/v1.8.0...v1.8.1) (2018-01-21)
165 |
166 | ### Bug Fixes
167 |
168 | * rename parentNode callback param to nextParentNode ([24bf39d](https://github.com/frontend-collective/react-sortable-tree/commit/24bf39d))
169 |
170 |
171 |
172 | # [1.8.0](https://github.com/frontend-collective/react-sortable-tree/compare/v1.7.0...v1.8.0) (2018-01-21)
173 |
174 | ### Features
175 |
176 | * Parent node in onMoveNode callback ([537c6a4](https://github.com/frontend-collective/react-sortable-tree/commit/537c6a4))
177 |
178 |
179 |
180 | # [1.7.0](https://github.com/frontend-collective/react-sortable-tree/compare/v1.6.0...v1.7.0) (2018-01-16)
181 |
182 | ### Features
183 |
184 | * add onDragStateChanged callback ([2caa9d1](https://github.com/frontend-collective/react-sortable-tree/commit/2caa9d1))
185 |
186 | onDragStateChanged is called when dragging begins and ends, so you can easily track the current state of dragging.
187 | Thanks to [@wuweiweiwu](https://github.com/wuweiweiwu) for the contribution!
188 |
189 |
190 |
191 | # [1.6.0](https://github.com/frontend-collective/react-sortable-tree/compare/v1.5.5...v1.6.0) (2018-01-14)
192 |
193 | ### Features
194 |
195 | * add more parameters to rowHeight. Fixes [#199](https://github.com/frontend-collective/react-sortable-tree/issues/199) ([8ff0ff2](https://github.com/frontend-collective/react-sortable-tree/commit/8ff0ff2))
196 |
197 | Thanks to [@wuweiweiwu](https://github.com/wuweiweiwu) for the contribution!
198 |
199 |
200 |
201 | ## [1.5.5](https://github.com/frontend-collective/react-sortable-tree/compare/v1.5.4...v1.5.5) (2018-01-13)
202 |
203 | ### Bug Fixes
204 |
205 | * expand tree for searches on initial mount. fixes [#223](https://github.com/frontend-collective/react-sortable-tree/issues/223) ([64a984a](https://github.com/frontend-collective/react-sortable-tree/commit/64a984a))
206 |
207 |
208 |
209 | ## [1.5.4](https://github.com/frontend-collective/react-sortable-tree/compare/v1.5.3...v1.5.4) (2018-01-07)
210 |
211 | ### Bug Fixes
212 |
213 | * UglifyJS enabled to remove dead code, which had been causing issues with some builds. If the presence of UglifyJS causes issues in your production builds, please refer to https://github.com/frontend-collective/react-sortable-tree#if-it-throws-typeerror-fn-is-not-a-function-errors-in-production
214 |
215 |
216 |
217 | ## [1.5.3](https://github.com/frontend-collective/react-sortable-tree/compare/v1.5.2...v1.5.3) (2017-12-09)
218 |
219 | ### Bug Fixes
220 |
221 | * dragging past the bottom of the tree no longer slows down rendering ([3ce35f3](https://github.com/frontend-collective/react-sortable-tree/commit/3ce35f3))
222 |
223 |
224 |
225 | ## [1.5.2](https://github.com/frontend-collective/react-sortable-tree/compare/v1.5.1...v1.5.2) (2017-11-28)
226 |
227 | ### Bug Fixes
228 |
229 | * correct positioning of full-width draggable rows ([00396d1](https://github.com/frontend-collective/react-sortable-tree/commit/00396d1))
230 |
231 |
232 |
233 | ## [1.5.1](https://github.com/frontend-collective/react-sortable-tree/compare/v1.5.0...v1.5.1) (2017-11-28)
234 |
235 | ### Bug Fixes
236 |
237 | * prevent slowdown caused by invalid targetDepth when using maxDepth ([c21d4de](https://github.com/frontend-collective/react-sortable-tree/commit/c21d4de)), closes [#194](https://github.com/frontend-collective/react-sortable-tree/issues/194)
238 |
239 |
240 |
241 | # [1.5.0](https://github.com/frontend-collective/react-sortable-tree/compare/v1.4.0...v1.5.0) (2017-10-29)
242 |
243 | ### Bug Fixes
244 |
245 | * Fix oblong collapse/expand button appearance on mobile safari ([62dfdec](https://github.com/frontend-collective/react-sortable-tree/commit/62dfdec))
246 |
247 | ### Features
248 |
249 | * enable the use of themes for simplified appearance customization ([d07c6a7](https://github.com/frontend-collective/react-sortable-tree/commit/d07c6a7))
250 |
251 |
252 |
253 | # [1.4.0](https://github.com/frontend-collective/react-sortable-tree/compare/v1.3.1...v1.4.0) (2017-10-13)
254 |
255 | ### Features
256 |
257 | * Add path argument to onVisibilityToggle callback ([25cd134](https://github.com/frontend-collective/react-sortable-tree/commit/25cd134))
258 |
259 |
260 |
261 | ## [1.3.1](https://github.com/frontend-collective/react-sortable-tree/compare/v1.3.0...v1.3.1) (2017-10-03)
262 |
263 | ### Bug Fixes
264 |
265 | * Allow react[@16](https://github.com/16) ([9a31a03](https://github.com/frontend-collective/react-sortable-tree/commit/9a31a03))
266 |
267 |
268 |
269 | # [1.3.0](https://github.com/frontend-collective/react-sortable-tree/compare/v1.2.2...v1.3.0) (2017-09-20)
270 |
271 | ### Features
272 |
273 | * Provide more row parameters in rowHeight callback ([1b88b18](https://github.com/frontend-collective/react-sortable-tree/commit/1b88b18))
274 |
275 |
276 |
277 | ## [1.2.2](https://github.com/frontend-collective/react-sortable-tree/compare/v1.2.1...v1.2.2) (2017-09-12)
278 |
279 | ### Bug Fixes
280 |
281 | * Specify version of react-dnd-html5-backend to avoid invalid package installs ([a09b611](https://github.com/frontend-collective/react-sortable-tree/commit/a09b611))
282 |
283 |
284 |
285 | ## [1.2.1](https://github.com/frontend-collective/react-sortable-tree/compare/v1.2.0...v1.2.1) (2017-09-06)
286 |
287 | ### Bug Fixes
288 |
289 | * Allow children function in default renderer ([6f1dcac](https://github.com/frontend-collective/react-sortable-tree/commit/6f1dcac))
290 |
291 |
292 |
293 | # [1.2.0](https://github.com/frontend-collective/react-sortable-tree/compare/v1.1.1...v1.2.0) (2017-08-12)
294 |
295 | ### Features
296 |
297 | * Add `shouldCopyOnOutsideDrop` prop to enable copying of nodes that leave the tree ([d6a9be9](https://github.com/frontend-collective/react-sortable-tree/commit/d6a9be9))
298 |
299 |
300 |
301 | ## [1.1.1](https://github.com/frontend-collective/react-sortable-tree/compare/v1.1.0...v1.1.1) (2017-08-06)
302 |
303 | ### Bug Fixes
304 |
305 | * **tree-to-tree:** Fix node depth when dragging between trees ([323ccad](https://github.com/frontend-collective/react-sortable-tree/commit/323ccad))
306 |
307 |
308 |
309 | # [1.1.0](https://github.com/frontend-collective/react-sortable-tree/compare/v1.0.0...v1.1.0) (2017-08-05)
310 |
311 | ### Features
312 |
313 | * **node-renderer:** Make title and subtitle insertable via props ([fff72c6](https://github.com/frontend-collective/react-sortable-tree/commit/fff72c6))
314 |
315 |
316 |
317 | # [1.0.0](https://github.com/frontend-collective/react-sortable-tree/compare/v0.1.21...v1.0.0) (2017-08-05)
318 |
319 | ### Bug Fixes
320 |
321 | * External node offset was shifted ([d1ae0eb](https://github.com/frontend-collective/react-sortable-tree/commit/d1ae0eb))
322 |
323 | ### Code Refactoring
324 |
325 | * get rid of `dndWrapExternalSource` api ([d103e9f](https://github.com/frontend-collective/react-sortable-tree/commit/d103e9f))
326 |
327 | ### Features
328 |
329 | * **tree-to-tree:** Enable tree-to-tree drag-and-drop ([6986a23](https://github.com/frontend-collective/react-sortable-tree/commit/6986a23))
330 | * Display droppable placeholder element when tree is empty ([2cd371c](https://github.com/frontend-collective/react-sortable-tree/commit/2cd371c))
331 | * Add `prevPath` and `prevTreeIndex` to the `onMoveNode` callback ([6986a23](https://github.com/frontend-collective/react-sortable-tree/commit/6986a23))
332 |
333 | ### BREAKING CHANGES
334 |
335 | * Trees that are empty now display a placeholder element
336 | in their place instead of being simply empty.
337 | * `dndWrapExternalSource` api no longer exists.
338 | You can achieve the same functionality and more with react-dnd
339 | APIs, as demonstrated in the storybook example.
340 |
341 |
342 |
343 | ## [0.1.21](https://github.com/frontend-collective/react-sortable-tree/compare/v0.1.20...v0.1.21) (2017-07-15)
344 |
345 | ### Bug Fixes
346 |
347 | * Remove console.log left in after development ([da27c47](https://github.com/frontend-collective/react-sortable-tree/commit/da27c47))
348 |
349 | See the GitHub [Releases](https://github.com/frontend-collective/react-sortable-tree/releases) for information on updates.
350 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | ## Our Standards
8 |
9 | Examples of behavior that contributes to creating a positive environment include:
10 |
11 | * Using welcoming and inclusive language
12 | * Being respectful of differing viewpoints and experiences
13 | * Gracefully accepting constructive criticism
14 | * Focusing on what is best for the community
15 | * Showing empathy towards other community members
16 |
17 | Examples of unacceptable behavior by participants include:
18 |
19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | * Trolling, insulting/derogatory comments, and personal or political attacks
21 | * Public or private harassment
22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | * Other conduct which could reasonably be considered inappropriate in a professional setting
24 |
25 | ## Our Responsibilities
26 |
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 |
29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | ## Scope
32 |
33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34 |
35 | ## Enforcement
36 |
37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@weiweiwu.me. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38 |
39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40 |
41 | ## Attribution
42 |
43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
44 |
45 | [homepage]: http://contributor-covenant.org
46 | [version]: http://contributor-covenant.org/version/1/4/
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Chris Fritz
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 | # Note on maintenance
2 |
3 | This library is not actively maintained. [Please find and discuss alternatives here](https://github.com/frontend-collective/react-sortable-tree/discussions/942).
4 |
5 |
6 |
7 |
8 |
9 | # React Sortable Tree
10 |
11 | 
12 | 
13 | [](https://npmcharts.com/compare/react-sortable-tree?minimal=true)
14 | [](https://npmcharts.com/compare/react-sortable-tree?minimal=true)
15 | [](https://travis-ci.org/frontend-collective/react-sortable-tree)
16 | [](https://coveralls.io/github/frontend-collective/react-sortable-tree?branch=master)
17 | [](http://makeapullrequest.com)
18 |
19 | > A React component for Drag-and-drop sortable representation of hierarchical data. Checkout the [Storybook](https://frontend-collective.github.io/react-sortable-tree/) for a demonstration of some basic and advanced features.
20 |
21 |
22 |
23 |
24 |
25 | ## Table of Contents
26 |
27 | - [Getting Started](#getting-started)
28 | - [Usage](#usage)
29 | - [Props](#props)
30 | - [Data Helpers](#data-helper-functions)
31 | - [Themes](#themes)
32 | - [Browser Compatibility](#browser-compatibility)
33 | - [Troubleshooting](#troubleshooting)
34 | - [Contributing](#contributing)
35 |
36 | ## Getting started
37 |
38 | Install `react-sortable-tree` using npm.
39 |
40 | ```sh
41 | # NPM
42 | npm install react-sortable-tree --save
43 |
44 | # YARN
45 | yarn add react-sortable-tree
46 | ```
47 |
48 | ES6 and CommonJS builds are available with each distribution.
49 | For example:
50 |
51 | ```js
52 | // This only needs to be done once; probably during your application's bootstrapping process.
53 | import 'react-sortable-tree/style.css';
54 |
55 | // You can import the default tree with dnd context
56 | import SortableTree from 'react-sortable-tree';
57 |
58 | // Or you can import the tree without the dnd context as a named export. eg
59 | import { SortableTreeWithoutDndContext as SortableTree } from 'react-sortable-tree';
60 |
61 | // Importing from cjs (default)
62 | import SortableTree from 'react-sortable-tree/dist/index.cjs.js';
63 | import SortableTree from 'react-sortable-tree';
64 |
65 | // Importing from esm
66 | import SortableTree from 'react-sortable-tree/dist/index.esm.js';
67 | ```
68 |
69 | ## Usage
70 |
71 | ```jsx
72 | import React, { Component } from 'react';
73 | import SortableTree from 'react-sortable-tree';
74 | import 'react-sortable-tree/style.css'; // This only needs to be imported once in your app
75 |
76 | export default class Tree extends Component {
77 | constructor(props) {
78 | super(props);
79 |
80 | this.state = {
81 | treeData: [
82 | { title: 'Chicken', children: [{ title: 'Egg' }] },
83 | { title: 'Fish', children: [{ title: 'fingerline' }] },
84 | ],
85 | };
86 | }
87 |
88 | render() {
89 | return (
90 |
91 | this.setState({ treeData })}
94 | />
95 |
96 | );
97 | }
98 | }
99 | ```
100 |
101 | ## Props
102 |
103 | | Prop | Type | Description
|
104 | | :----------------------------- | :------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
105 | | treeData _(required)_ | object[] | Tree data with the following keys: `title` is the primary label for the node.
`subtitle` is a secondary label for the node.
`expanded` shows children of the node if true, or hides them if false. Defaults to false.
`children` is an array of child nodes belonging to the node.
**Example**: `[{title: 'main', subtitle: 'sub'}, { title: 'value2', expanded: true, children: [{ title: 'value3') }] }]` |
106 | | onChange
_(required)_ | func | Called whenever tree data changed. Just like with React input elements, you have to update your own component's data to see the changes reflected.
`( treeData: object[] ): void`
|
107 | | getNodeKey
_(recommended)_ | func | Specify the unique key used to identify each node and generate the `path` array passed in callbacks. With a setting of `getNodeKey={({ node }) => node.id}`, for example, in callbacks this will let you easily determine that the node with an `id` of `35` is (or has just become) a child of the node with an `id` of `12`, which is a child of ... and so on. It uses [`defaultGetNodeKey`](https://github.com/frontend-collective/react-sortable-tree/blob/master/src/utils/default-handlers.js) by default, which returns the index in the tree (omitting hidden nodes).
`({ node: object, treeIndex: number }): string or number`
|
108 | | generateNodeProps | func | Generate an object with additional props to be passed to the node renderer. Use this for adding buttons via the `buttons` key, or additional `style` / `className` settings.
`({ node: object, path: number[] or string[], treeIndex: number, lowerSiblingCounts: number[], isSearchMatch: bool, isSearchFocus: bool }): object`
|
109 | | onMoveNode | func | Called after node move operation.
`({ treeData: object[], node: object, nextParentNode: object, prevPath: number[] or string[], prevTreeIndex: number, nextPath: number[] or string[], nextTreeIndex: number }): void`
|
110 | | onVisibilityToggle | func | Called after children nodes collapsed or expanded.
`({ treeData: object[], node: object, expanded: bool, path: number[] or string[] }): void`
|
111 | | onDragStateChanged | func | Called when a drag is initiated or ended.
`({ isDragging: bool, draggedNode: object }): void`
|
112 | | maxDepth | number | Maximum depth nodes can be inserted at. Defaults to infinite. |
113 | | rowDirection | string | Adds row direction support if set to `'rtl'` Defaults to `'ltr'`. |
114 | | canDrag | func or bool | Return false from callback to prevent node from dragging, by hiding the drag handle. Set prop to `false` to disable dragging on all nodes. Defaults to `true`.
`({ node: object, path: number[] or string[], treeIndex: number, lowerSiblingCounts: number[], isSearchMatch: bool, isSearchFocus: bool }): bool`
|
115 | | canDrop | func | Return false to prevent node from dropping in the given location.
`({ node: object, prevPath: number[] or string[], prevParent: object, prevTreeIndex: number, nextPath: number[] or string[], nextParent: object, nextTreeIndex: number }): bool`
|
116 | | canNodeHaveChildren | func | Function to determine whether a node can have children, useful for preventing hover preview when you have a `canDrop` condition. Default is set to a function that returns `true`. Functions should be of type `(node): bool`. |
117 | | theme | object | Set an all-in-one packaged appearance for the tree. See the [Themes](#themes) section for more information. |
118 | | searchMethod | func | The method used to search nodes. Defaults to [`defaultSearchMethod`](https://github.com/frontend-collective/react-sortable-tree/blob/master/src/utils/default-handlers.js), which uses the `searchQuery` string to search for nodes with matching `title` or `subtitle` values. NOTE: Changing `searchMethod` will not update the search, but changing the `searchQuery` will.
`({ node: object, path: number[] or string[], treeIndex: number, searchQuery: any }): bool`
|
119 | | searchQuery | string or any | Used by the `searchMethod` to highlight and scroll to matched nodes. Should be a string for the default `searchMethod`, but can be anything when using a custom search. Defaults to `null`. |
120 | | searchFocusOffset | number | Outline the <`searchFocusOffset`>th node and scroll to it. |
121 | | onlyExpandSearchedNodes | boolean | Only expand the nodes that match searches. Collapses all other nodes. Defaults to `false`. |
122 | | searchFinishCallback | func | Get the nodes that match the search criteria. Used for counting total matches, etc.
`(matches: { node: object, path: number[] or string[], treeIndex: number }[]): void`
|
123 | | dndType | string | String value used by [react-dnd](https://react-dnd.github.io/react-dnd/about) (see overview at the link) for dropTargets and dragSources types. If not set explicitly, a default value is applied by react-sortable-tree for you for its internal use. **NOTE:** Must be explicitly set and the same value used in order for correct functioning of external nodes |
124 | | shouldCopyOnOutsideDrop | func or bool | Return true, or a callback returning true, and dropping nodes to react-dnd drop targets outside of the tree will not remove them from the tree. Defaults to `false`.
`({ node: object, prevPath: number[] or string[], prevTreeIndex: number, }): bool`
|
125 | | reactVirtualizedListProps | object | Custom properties to hand to the internal [react-virtualized List](https://github.com/bvaughn/react-virtualized/blob/master/docs/List.md#prop-types) |
126 | | style | object | Style applied to the container wrapping the tree (style defaults to `{height: '100%'}`) |
127 | | innerStyle | object | Style applied to the inner, scrollable container (for padding, etc.) |
128 | | className | string | Class name for the container wrapping the tree |
129 | | rowHeight | number or func | Used by react-sortable-tree. Defaults to `62`. Either a fixed row height (number) or a function that returns the height of a row given its index: `({ treeIndex: number, node: object, path: number[] or string[] }): number` |
130 | | slideRegionSize | number | Size in px of the region near the edges that initiates scrolling on dragover. Defaults to `100`. |
131 | | scaffoldBlockPxWidth | number | The width of the blocks containing the lines representing the structure of the tree. Defaults to `44`. |
132 | | isVirtualized | bool | Set to false to disable virtualization. Defaults to `true`. **NOTE**: Auto-scrolling while dragging, and scrolling to the `searchFocusOffset` will be disabled. |
133 | | nodeContentRenderer | any | Override the default component ([`NodeRendererDefault`](https://github.com/frontend-collective/react-sortable-tree/blob/master/src/node-renderer-default.js)) for rendering nodes (but keep the scaffolding generator). This is a last resort for customization - most custom styling should be able to be solved with `generateNodeProps`, a `theme` or CSS rules. If you must use it, is best to copy the component in `node-renderer-default.js` to use as a base, and customize as needed. |
134 | | placeholderRenderer | any | Override the default placeholder component ([`PlaceholderRendererDefault`](https://github.com/frontend-collective/react-sortable-tree/blob/master/src/placeholder-renderer-default.js)) which is displayed when the tree is empty. This is an advanced option, and in most cases should probably be solved with a `theme` or custom CSS instead. |
135 |
136 | ## Data Helper Functions
137 |
138 | Need a hand turning your flat data into nested tree data?
139 | Want to perform add/remove operations on the tree data without creating your own recursive function?
140 | Check out the helper functions exported from [`tree-data-utils.js`](https://github.com/frontend-collective/react-sortable-tree/blob/master/src/utils/tree-data-utils.js).
141 |
142 | - **`getTreeFromFlatData`**: Convert flat data (like that from a database) into nested tree data.
143 | - **`getFlatDataFromTree`**: Convert tree data back to flat data.
144 | - **`addNodeUnderParent`**: Add a node under the parent node at the given path.
145 | - **`removeNode`**: For a given path, get the node at that path, treeIndex, and the treeData with that node removed.
146 | - **`removeNodeAtPath`**: For a given path, remove the node and return the treeData.
147 | - **`changeNodeAtPath`**: Modify the node object at the given path.
148 | - **`map`**: Perform a change on every node in the tree.
149 | - **`walk`**: Visit every node in the tree in order.
150 | - **`getDescendantCount`**: Count how many descendants this node has.
151 | - **`getVisibleNodeCount`**: Count how many visible descendants this node has.
152 | - **`getVisibleNodeInfoAtIndex`**: Get the
th visible node in the tree data.
153 | - **`toggleExpandedForAll`**: Expand or close every node in the tree.
154 | - **`getNodeAtPath`**: Get the node at the input path.
155 | - **`insertNode`**: Insert the input node at the specified depth and minimumTreeIndex.
156 | - **`find`**: Find nodes matching a search query in the tree.
157 | - **`isDescendant`**: Check if a node is a descendant of another node.
158 | - **`getDepth`**: Get the longest path in the tree.
159 |
160 | ## Themes
161 |
162 | Using the `theme` prop along with an imported theme module, you can easily override the default appearance with another standard one.
163 |
164 | ### Featured themes
165 |
166 | |  | | |
167 | | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------: |
168 | | **File Explorer** | **Full Node Drag** | **Minimalistic theme inspired from MATERIAL UI** |
169 | | react-sortable-tree-theme-file-explorer | react-sortable-tree-theme-full-node-drag | react-sortable-tree-theme-minimal |
170 | | [Github](https://github.com/frontend-collective/react-sortable-tree-theme-file-explorer) \| [NPM](https://www.npmjs.com/package/react-sortable-tree-theme-file-explorer) | [Github](https://github.com/frontend-collective/react-sortable-tree-theme-full-node-drag) \| [NPM](https://www.npmjs.com/package/react-sortable-tree-theme-full-node-drag) | [Github](https://github.com/lifejuggler/react-sortable-tree-theme-minimal) \| [NPM](https://www.npmjs.com/package/react-sortable-tree-theme-minimal) |
171 |
172 | **Help Wanted** - As the themes feature has just been enabled, there are very few (only _two_ at the time of this writing) theme modules available. If you've customized the appearance of your tree to be especially cool or easy to use, I would be happy to feature it in this readme with a link to the Github repo and NPM page if you convert it to a theme. You can use my [file explorer theme repo](https://github.com/frontend-collective/react-sortable-tree-theme-file-explorer) as a template to plug in your own stuff.
173 |
174 | ## Browser Compatibility
175 |
176 | | Browser | Works? |
177 | | :------ | :----- |
178 | | Chrome | Yes |
179 | | Firefox | Yes |
180 | | Safari | Yes |
181 | | IE 11 | Yes |
182 |
183 | ## Troubleshooting
184 |
185 | ### If it throws "TypeError: fn is not a function" errors in production
186 |
187 | This issue may be related to an ongoing incompatibility between UglifyJS and Webpack's behavior. See an explanation at [create-react-app#2376](https://github.com/facebookincubator/create-react-app/issues/2376).
188 |
189 | The simplest way to mitigate this issue is by adding `comparisons: false` to your Uglify config as seen here: https://github.com/facebookincubator/create-react-app/pull/2379/files
190 |
191 | ### If it doesn't work with other components that use react-dnd
192 |
193 | react-dnd only allows for one DragDropContext at a time (see: https://github.com/gaearon/react-dnd/issues/186). To get around this, you can import the context-less tree component via `SortableTreeWithoutDndContext`.
194 |
195 | ```js
196 | // before
197 | import SortableTree from 'react-sortable-tree';
198 |
199 | // after
200 | import { SortableTreeWithoutDndContext as SortableTree } from 'react-sortable-tree';
201 | ```
202 |
203 | ## Contributing
204 |
205 | Please read the [Code of Conduct](CODE_OF_CONDUCT.md). I actively welcome pull requests :)
206 |
207 | After cloning the repository and running `yarn install` inside, you can use the following commands to develop and build the project.
208 |
209 | ```sh
210 | # Starts a webpack dev server that hosts a demo page with the component.
211 | # It uses react-hot-loader so changes are reflected on save.
212 | yarn start
213 |
214 | # Start the storybook, which has several different examples to play with.
215 | # Also hot-reloaded.
216 | yarn run storybook
217 |
218 | # Runs the library tests
219 | yarn test
220 |
221 | # Lints the code with eslint
222 | yarn run lint
223 |
224 | # Lints and builds the code, placing the result in the dist directory.
225 | # This build is necessary to reflect changes if you're
226 | # `npm link`-ed to this repository from another local project.
227 | yarn run build
228 | ```
229 |
230 | Pull requests are welcome!
231 |
232 | ## License
233 |
234 | MIT
235 |
--------------------------------------------------------------------------------
/__mocks__/fileMock.js:
--------------------------------------------------------------------------------
1 | module.exports = 'test-file-stub';
2 |
--------------------------------------------------------------------------------
/__mocks__/react-dnd-html5-backend.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import { TestBackend } from 'react-dnd-test-backend';
3 |
4 | module.exports = { HTML5Backend: TestBackend };
5 |
--------------------------------------------------------------------------------
/__mocks__/react-dnd-scrollzone.js:
--------------------------------------------------------------------------------
1 | module.exports = el => el;
2 | module.exports.createVerticalStrength = () => {};
3 | module.exports.createHorizontalStrength = () => {};
4 |
--------------------------------------------------------------------------------
/__mocks__/react-virtualized.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // eslint-disable-next-line global-require
4 | const reactVirtualized = { ...require('react-virtualized') };
5 |
6 | /* eslint-disable react/prop-types */
7 | const MockAutoSizer = props =>
8 |
9 | {props.children({
10 | height: 99999,
11 | width: 200,
12 | })}
13 |
;
14 | /* eslint-enable react/prop-types */
15 |
16 | reactVirtualized.AutoSizer = MockAutoSizer;
17 |
18 | module.exports = reactVirtualized;
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-sortable-tree",
3 | "version": "2.8.0",
4 | "description": "Drag-and-drop sortable component for nested data and hierarchies",
5 | "scripts": {
6 | "prebuild": "yarn run lint && yarn run clean",
7 | "build": "rollup -c",
8 | "build:storybook": "build-storybook -o build",
9 | "clean": "rimraf dist",
10 | "clean:storybook": "rimraf build",
11 | "lint": "eslint src",
12 | "prettier": "prettier --write \"{src,example/src,stories}/**/*.{js,css,md}\"",
13 | "prepublishOnly": "yarn run test && yarn run build",
14 | "release": "standard-version",
15 | "test": "jest",
16 | "test:watch": "jest --watchAll",
17 | "storybook": "start-storybook -p ${PORT:-3001} -h 0.0.0.0",
18 | "deploy": "gh-pages -d build"
19 | },
20 | "main": "dist/index.cjs.js",
21 | "module": "dist/index.esm.js",
22 | "files": [
23 | "dist",
24 | "style.css"
25 | ],
26 | "repository": {
27 | "type": "git",
28 | "url": "https://github.com/frontend-collective/react-sortable-tree"
29 | },
30 | "homepage": "https://frontend-collective.github.io/react-sortable-tree/",
31 | "bugs": "https://github.com/frontend-collective/react-sortable-tree/issues",
32 | "authors": [
33 | "Chris Fritz"
34 | ],
35 | "license": "MIT",
36 | "jest": {
37 | "setupFilesAfterEnv": [
38 | "./node_modules/jest-enzyme/lib/index.js"
39 | ],
40 | "setupFiles": [
41 | "./test-config/shim.js",
42 | "./test-config/test-setup.js"
43 | ],
44 | "moduleFileExtensions": [
45 | "js",
46 | "jsx",
47 | "json"
48 | ],
49 | "moduleDirectories": [
50 | "node_modules"
51 | ],
52 | "moduleNameMapper": {
53 | "\\.(css|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js",
54 | "^dnd-core$": "dnd-core/dist/cjs",
55 | "^react-dnd$": "react-dnd/dist/cjs",
56 | "^react-dnd-html5-backend$": "react-dnd-html5-backend/dist/cjs",
57 | "^react-dnd-touch-backend$": "react-dnd-touch-backend/dist/cjs",
58 | "^react-dnd-test-backend$": "react-dnd-test-backend/dist/cjs",
59 | "^react-dnd-test-utils$": "react-dnd-test-utils/dist/cjs"
60 | }
61 | },
62 | "browserslist": [
63 | "IE 11",
64 | "last 2 versions",
65 | "> 1%"
66 | ],
67 | "dependencies": {
68 | "frontend-collective-react-dnd-scrollzone": "^1.0.2",
69 | "lodash.isequal": "^4.5.0",
70 | "prop-types": "^15.6.1",
71 | "react-dnd": "^11.1.3",
72 | "react-dnd-html5-backend": "^11.1.3",
73 | "react-lifecycles-compat": "^3.0.4",
74 | "react-virtualized": "^9.21.2"
75 | },
76 | "peerDependencies": {
77 | "react": "^16.3.0",
78 | "react-dnd": "^7.3.0",
79 | "react-dom": "^16.3.0"
80 | },
81 | "devDependencies": {
82 | "@babel/cli": "^7.7.0",
83 | "@babel/core": "^7.7.2",
84 | "@babel/plugin-transform-modules-commonjs": "^7.1.0",
85 | "@babel/preset-env": "^7.7.1",
86 | "@babel/preset-react": "^7.7.0",
87 | "@storybook/addon-storyshots": "^5.2.6",
88 | "@storybook/addons": "^5.3.17",
89 | "@storybook/react": "^5.2.6",
90 | "@storybook/theming": "^5.3.17",
91 | "autoprefixer": "^9.7.1",
92 | "babel-core": "^7.0.0-bridge.0",
93 | "babel-eslint": "^10.0.3",
94 | "babel-jest": "^24.9.0",
95 | "babel-loader": "^8.0.4",
96 | "codesandbox": "~2.1.10",
97 | "coveralls": "^3.0.1",
98 | "cross-env": "^6.0.3",
99 | "enzyme": "^3.10.0",
100 | "enzyme-adapter-react-16": "^1.14.0",
101 | "eslint": "^6.6.0",
102 | "eslint-config-airbnb": "^18.0.1",
103 | "eslint-config-prettier": "^6.5.0",
104 | "eslint-plugin-import": "^2.18.2",
105 | "eslint-plugin-jsx-a11y": "^6.2.3",
106 | "eslint-plugin-react": "^7.16.0",
107 | "gh-pages": "^2.1.1",
108 | "jest": "^24.9.0",
109 | "jest-enzyme": "^7.1.2",
110 | "parcel-bundler": "^1.12.4",
111 | "prettier": "^1.19.1",
112 | "react": "^16.11.0",
113 | "react-addons-shallow-compare": "^15.6.2",
114 | "react-dnd-test-backend": "^11.1.3",
115 | "react-dnd-touch-backend": "^9.4.0",
116 | "react-dom": "^16.11.0",
117 | "react-hot-loader": "^4.12.17",
118 | "react-sortable-tree-theme-file-explorer": "^2.0.0",
119 | "react-test-renderer": "^16.11.0",
120 | "rimraf": "^3.0.0",
121 | "rollup": "^1.27.0",
122 | "rollup-plugin-babel": "^4.0.3",
123 | "rollup-plugin-commonjs": "^10.1.0",
124 | "rollup-plugin-node-resolve": "^5.2.0",
125 | "rollup-plugin-postcss": "^2.0.3",
126 | "standard-version": "^8.0.1"
127 | },
128 | "keywords": [
129 | "react",
130 | "react-component"
131 | ]
132 | }
133 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import nodeResolve from 'rollup-plugin-node-resolve';
2 | import commonjs from 'rollup-plugin-commonjs';
3 | import babel from 'rollup-plugin-babel';
4 | import postcss from 'rollup-plugin-postcss';
5 |
6 | import pkg from './package.json';
7 |
8 | export default {
9 | input: './src/index.js',
10 | output: [
11 | {
12 | file: pkg.main,
13 | format: 'cjs',
14 | exports: 'named',
15 | },
16 | {
17 | file: pkg.module,
18 | format: 'esm',
19 | exports: 'named',
20 | },
21 | ],
22 | external: [
23 | 'react',
24 | 'react-dom',
25 | 'react-dnd',
26 | 'prop-types',
27 | 'react-dnd-html5-backend',
28 | 'frontend-collective-react-dnd-scrollzone',
29 | 'react-virtualized',
30 | 'lodash.isequal',
31 | ],
32 | plugins: [
33 | nodeResolve(),
34 | postcss({ extract: './style.css' }),
35 | commonjs({
36 | include: 'node_modules/**',
37 | }),
38 | babel({
39 | exclude: 'node_modules/**',
40 | }),
41 | ],
42 | };
43 |
--------------------------------------------------------------------------------
/src/__snapshots__/react-sortable-tree.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` should render tree correctly 1`] = `
4 |
12 |
13 |
34 |
49 |
61 |
69 |
77 |
84 |
87 |
95 |
98 |
101 |
104 |
107 |
108 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 | `;
122 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import SortableTree, {
2 | SortableTreeWithoutDndContext,
3 | } from './react-sortable-tree';
4 |
5 | export * from './utils/default-handlers';
6 | export * from './utils/tree-data-utils';
7 | export default SortableTree;
8 |
9 | // Export the tree component without the react-dnd DragDropContext,
10 | // for when component is used with other components using react-dnd.
11 | // see: https://github.com/gaearon/react-dnd/issues/186
12 | export { SortableTreeWithoutDndContext };
13 |
--------------------------------------------------------------------------------
/src/node-renderer-default.css:
--------------------------------------------------------------------------------
1 | .rst__rowWrapper {
2 | padding: 10px 10px 10px 0;
3 | height: 100%;
4 | box-sizing: border-box;
5 | }
6 |
7 | .rst__rtl.rst__rowWrapper {
8 | padding: 10px 0 10px 10px;
9 | }
10 |
11 | .rst__row {
12 | height: 100%;
13 | white-space: nowrap;
14 | display: flex;
15 | }
16 | .rst__row > * {
17 | box-sizing: border-box;
18 | }
19 |
20 | /**
21 | * The outline of where the element will go if dropped, displayed while dragging
22 | */
23 | .rst__rowLandingPad,
24 | .rst__rowCancelPad {
25 | border: none !important;
26 | box-shadow: none !important;
27 | outline: none !important;
28 | }
29 | .rst__rowLandingPad > *,
30 | .rst__rowCancelPad > * {
31 | opacity: 0 !important;
32 | }
33 | .rst__rowLandingPad::before,
34 | .rst__rowCancelPad::before {
35 | background-color: lightblue;
36 | border: 3px dashed white;
37 | content: '';
38 | position: absolute;
39 | top: 0;
40 | right: 0;
41 | bottom: 0;
42 | left: 0;
43 | z-index: -1;
44 | }
45 |
46 | /**
47 | * Alternate appearance of the landing pad when the dragged location is invalid
48 | */
49 | .rst__rowCancelPad::before {
50 | background-color: #e6a8ad;
51 | }
52 |
53 | /**
54 | * Nodes matching the search conditions are highlighted
55 | */
56 | .rst__rowSearchMatch {
57 | outline: solid 3px #0080ff;
58 | }
59 |
60 | /**
61 | * The node that matches the search conditions and is currently focused
62 | */
63 | .rst__rowSearchFocus {
64 | outline: solid 3px #fc6421;
65 | }
66 |
67 | .rst__rowContents,
68 | .rst__rowLabel,
69 | .rst__rowToolbar,
70 | .rst__moveHandle,
71 | .rst__toolbarButton {
72 | display: inline-block;
73 | vertical-align: middle;
74 | }
75 |
76 | .rst__rowContents {
77 | position: relative;
78 | height: 100%;
79 | border: solid #bbb 1px;
80 | border-left: none;
81 | box-shadow: 0 2px 2px -2px;
82 | padding: 0 5px 0 10px;
83 | border-radius: 2px;
84 | min-width: 230px;
85 | flex: 1 0 auto;
86 | display: flex;
87 | align-items: center;
88 | justify-content: space-between;
89 | background-color: white;
90 | }
91 |
92 | .rst__rtl.rst__rowContents {
93 | border-right: none;
94 | border-left: solid #bbb 1px;
95 | padding: 0 10px 0 5px;
96 | }
97 |
98 | .rst__rowContentsDragDisabled {
99 | border-left: solid #bbb 1px;
100 | }
101 |
102 | .rst__rtl.rst__rowContentsDragDisabled {
103 | border-right: solid #bbb 1px;
104 | border-left: solid #bbb 1px;
105 | }
106 |
107 | .rst__rowLabel {
108 | flex: 0 1 auto;
109 | padding-right: 20px;
110 | }
111 | .rst__rtl.rst__rowLabel {
112 | padding-left: 20px;
113 | padding-right: inherit;
114 | }
115 |
116 | .rst__rowToolbar {
117 | flex: 0 1 auto;
118 | display: flex;
119 | }
120 |
121 | .rst__moveHandle,
122 | .rst__loadingHandle {
123 | height: 100%;
124 | width: 44px;
125 | background: #d9d9d9
126 | url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MiIgaGVpZ2h0PSI0MiI+PGcgc3Ryb2tlPSIjRkZGIiBzdHJva2Utd2lkdGg9IjIuOSIgPjxwYXRoIGQ9Ik0xNCAxNS43aDE0LjQiLz48cGF0aCBkPSJNMTQgMjEuNGgxNC40Ii8+PHBhdGggZD0iTTE0IDI3LjFoMTQuNCIvPjwvZz4KPC9zdmc+')
127 | no-repeat center;
128 | border: solid #aaa 1px;
129 | box-shadow: 0 2px 2px -2px;
130 | cursor: move;
131 | border-radius: 1px;
132 | z-index: 1;
133 | }
134 |
135 | .rst__loadingHandle {
136 | cursor: default;
137 | background: #d9d9d9;
138 | }
139 |
140 | @keyframes pointFade {
141 | 0%,
142 | 19.999%,
143 | 100% {
144 | opacity: 0;
145 | }
146 | 20% {
147 | opacity: 1;
148 | }
149 | }
150 |
151 | .rst__loadingCircle {
152 | width: 80%;
153 | height: 80%;
154 | margin: 10%;
155 | position: relative;
156 | }
157 |
158 | .rst__loadingCirclePoint {
159 | width: 100%;
160 | height: 100%;
161 | position: absolute;
162 | left: 0;
163 | top: 0;
164 | }
165 |
166 | .rst__rtl.rst__loadingCirclePoint {
167 | right: 0;
168 | left: initial;
169 | }
170 |
171 | .rst__loadingCirclePoint::before {
172 | content: '';
173 | display: block;
174 | margin: 0 auto;
175 | width: 11%;
176 | height: 30%;
177 | background-color: #fff;
178 | border-radius: 30%;
179 | animation: pointFade 800ms infinite ease-in-out both;
180 | }
181 | .rst__loadingCirclePoint:nth-of-type(1) {
182 | transform: rotate(0deg);
183 | }
184 | .rst__loadingCirclePoint:nth-of-type(7) {
185 | transform: rotate(180deg);
186 | }
187 | .rst__loadingCirclePoint:nth-of-type(1)::before,
188 | .rst__loadingCirclePoint:nth-of-type(7)::before {
189 | animation-delay: -800ms;
190 | }
191 | .rst__loadingCirclePoint:nth-of-type(2) {
192 | transform: rotate(30deg);
193 | }
194 | .rst__loadingCirclePoint:nth-of-type(8) {
195 | transform: rotate(210deg);
196 | }
197 | .rst__loadingCirclePoint:nth-of-type(2)::before,
198 | .rst__loadingCirclePoint:nth-of-type(8)::before {
199 | animation-delay: -666ms;
200 | }
201 | .rst__loadingCirclePoint:nth-of-type(3) {
202 | transform: rotate(60deg);
203 | }
204 | .rst__loadingCirclePoint:nth-of-type(9) {
205 | transform: rotate(240deg);
206 | }
207 | .rst__loadingCirclePoint:nth-of-type(3)::before,
208 | .rst__loadingCirclePoint:nth-of-type(9)::before {
209 | animation-delay: -533ms;
210 | }
211 | .rst__loadingCirclePoint:nth-of-type(4) {
212 | transform: rotate(90deg);
213 | }
214 | .rst__loadingCirclePoint:nth-of-type(10) {
215 | transform: rotate(270deg);
216 | }
217 | .rst__loadingCirclePoint:nth-of-type(4)::before,
218 | .rst__loadingCirclePoint:nth-of-type(10)::before {
219 | animation-delay: -400ms;
220 | }
221 | .rst__loadingCirclePoint:nth-of-type(5) {
222 | transform: rotate(120deg);
223 | }
224 | .rst__loadingCirclePoint:nth-of-type(11) {
225 | transform: rotate(300deg);
226 | }
227 | .rst__loadingCirclePoint:nth-of-type(5)::before,
228 | .rst__loadingCirclePoint:nth-of-type(11)::before {
229 | animation-delay: -266ms;
230 | }
231 | .rst__loadingCirclePoint:nth-of-type(6) {
232 | transform: rotate(150deg);
233 | }
234 | .rst__loadingCirclePoint:nth-of-type(12) {
235 | transform: rotate(330deg);
236 | }
237 | .rst__loadingCirclePoint:nth-of-type(6)::before,
238 | .rst__loadingCirclePoint:nth-of-type(12)::before {
239 | animation-delay: -133ms;
240 | }
241 | .rst__loadingCirclePoint:nth-of-type(7) {
242 | transform: rotate(180deg);
243 | }
244 | .rst__loadingCirclePoint:nth-of-type(13) {
245 | transform: rotate(360deg);
246 | }
247 | .rst__loadingCirclePoint:nth-of-type(7)::before,
248 | .rst__loadingCirclePoint:nth-of-type(13)::before {
249 | animation-delay: 0ms;
250 | }
251 |
252 | .rst__rowTitle {
253 | font-weight: bold;
254 | }
255 |
256 | .rst__rowTitleWithSubtitle {
257 | font-size: 85%;
258 | display: block;
259 | height: 0.8rem;
260 | }
261 |
262 | .rst__rowSubtitle {
263 | font-size: 70%;
264 | line-height: 1;
265 | }
266 |
267 | .rst__collapseButton,
268 | .rst__expandButton {
269 | appearance: none;
270 | border: none;
271 | position: absolute;
272 | border-radius: 100%;
273 | box-shadow: 0 0 0 1px #000;
274 | width: 16px;
275 | height: 16px;
276 | padding: 0;
277 | top: 50%;
278 | transform: translate(-50%, -50%);
279 | cursor: pointer;
280 | }
281 | .rst__rtl.rst__collapseButton,
282 | .rst__rtl.rst__expandButton {
283 | transform: translate(50%, -50%);
284 | }
285 | .rst__collapseButton:focus,
286 | .rst__expandButton:focus {
287 | outline: none;
288 | box-shadow: 0 0 0 1px #000, 0 0 1px 3px #83bef9;
289 | }
290 | .rst__collapseButton:hover:not(:active),
291 | .rst__expandButton:hover:not(:active) {
292 | background-size: 24px;
293 | height: 20px;
294 | width: 20px;
295 | }
296 |
297 | .rst__collapseButton {
298 | background: #fff
299 | url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCI+PGNpcmNsZSBjeD0iOSIgY3k9IjkiIHI9IjgiIGZpbGw9IiNGRkYiLz48ZyBzdHJva2U9IiM5ODk4OTgiIHN0cm9rZS13aWR0aD0iMS45IiA+PHBhdGggZD0iTTQuNSA5aDkiLz48L2c+Cjwvc3ZnPg==')
300 | no-repeat center;
301 | }
302 |
303 | .rst__expandButton {
304 | background: #fff
305 | url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCI+PGNpcmNsZSBjeD0iOSIgY3k9IjkiIHI9IjgiIGZpbGw9IiNGRkYiLz48ZyBzdHJva2U9IiM5ODk4OTgiIHN0cm9rZS13aWR0aD0iMS45IiA+PHBhdGggZD0iTTQuNSA5aDkiLz48cGF0aCBkPSJNOSA0LjV2OSIvPjwvZz4KPC9zdmc+')
306 | no-repeat center;
307 | }
308 |
309 | /**
310 | * Line for under a node with children
311 | */
312 | .rst__lineChildren {
313 | height: 100%;
314 | display: inline-block;
315 | position: absolute;
316 | }
317 | .rst__lineChildren::after {
318 | content: '';
319 | position: absolute;
320 | background-color: black;
321 | width: 1px;
322 | left: 50%;
323 | bottom: 0;
324 | height: 10px;
325 | }
326 |
327 | .rst__rtl.rst__lineChildren::after {
328 | right: 50%;
329 | left: initial;
330 | }
331 |
--------------------------------------------------------------------------------
/src/node-renderer-default.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { isDescendant } from './utils/tree-data-utils';
4 | import classnames from './utils/classnames';
5 | import './node-renderer-default.css';
6 |
7 | class NodeRendererDefault extends Component {
8 | render() {
9 | const {
10 | scaffoldBlockPxWidth,
11 | toggleChildrenVisibility,
12 | connectDragPreview,
13 | connectDragSource,
14 | isDragging,
15 | canDrop,
16 | canDrag,
17 | node,
18 | title,
19 | subtitle,
20 | draggedNode,
21 | path,
22 | treeIndex,
23 | isSearchMatch,
24 | isSearchFocus,
25 | buttons,
26 | className,
27 | style,
28 | didDrop,
29 | treeId,
30 | isOver, // Not needed, but preserved for other renderers
31 | parentNode, // Needed for dndManager
32 | rowDirection,
33 | ...otherProps
34 | } = this.props;
35 | const nodeTitle = title || node.title;
36 | const nodeSubtitle = subtitle || node.subtitle;
37 | const rowDirectionClass = rowDirection === 'rtl' ? 'rst__rtl' : null;
38 |
39 | let handle;
40 | if (canDrag) {
41 | if (typeof node.children === 'function' && node.expanded) {
42 | // Show a loading symbol on the handle when the children are expanded
43 | // and yet still defined by a function (a callback to fetch the children)
44 | handle = (
45 |
46 |
47 | {[...new Array(12)].map((_, index) => (
48 |
56 | ))}
57 |
58 |
59 | );
60 | } else {
61 | // Show the handle used to initiate a drag-and-drop
62 | handle = connectDragSource(
, {
63 | dropEffect: 'copy',
64 | });
65 | }
66 | }
67 |
68 | const isDraggedDescendant = draggedNode && isDescendant(draggedNode, node);
69 | const isLandingPadActive = !didDrop && isDragging;
70 |
71 | let buttonStyle = { left: -0.5 * scaffoldBlockPxWidth };
72 | if (rowDirection === 'rtl') {
73 | buttonStyle = { right: -0.5 * scaffoldBlockPxWidth };
74 | }
75 |
76 | return (
77 |
78 | {toggleChildrenVisibility &&
79 | node.children &&
80 | (node.children.length > 0 || typeof node.children === 'function') && (
81 |
82 |
91 | toggleChildrenVisibility({
92 | node,
93 | path,
94 | treeIndex,
95 | })
96 | }
97 | />
98 |
99 | {node.expanded && !isDragging && (
100 |
104 | )}
105 |
106 | )}
107 |
108 |
109 | {/* Set the row preview to be used during drag and drop */}
110 | {connectDragPreview(
111 |
126 | {handle}
127 |
128 |
135 |
136 |
142 | {typeof nodeTitle === 'function'
143 | ? nodeTitle({
144 | node,
145 | path,
146 | treeIndex,
147 | })
148 | : nodeTitle}
149 |
150 |
151 | {nodeSubtitle && (
152 |
153 | {typeof nodeSubtitle === 'function'
154 | ? nodeSubtitle({
155 | node,
156 | path,
157 | treeIndex,
158 | })
159 | : nodeSubtitle}
160 |
161 | )}
162 |
163 |
164 |
165 | {buttons.map((btn, index) => (
166 |
170 | {btn}
171 |
172 | ))}
173 |
174 |
175 |
176 | )}
177 |
178 |
179 | );
180 | }
181 | }
182 |
183 | NodeRendererDefault.defaultProps = {
184 | isSearchMatch: false,
185 | isSearchFocus: false,
186 | canDrag: false,
187 | toggleChildrenVisibility: null,
188 | buttons: [],
189 | className: '',
190 | style: {},
191 | parentNode: null,
192 | draggedNode: null,
193 | canDrop: false,
194 | title: null,
195 | subtitle: null,
196 | rowDirection: 'ltr',
197 | };
198 |
199 | NodeRendererDefault.propTypes = {
200 | node: PropTypes.shape({}).isRequired,
201 | title: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
202 | subtitle: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
203 | path: PropTypes.arrayOf(
204 | PropTypes.oneOfType([PropTypes.string, PropTypes.number])
205 | ).isRequired,
206 | treeIndex: PropTypes.number.isRequired,
207 | treeId: PropTypes.string.isRequired,
208 | isSearchMatch: PropTypes.bool,
209 | isSearchFocus: PropTypes.bool,
210 | canDrag: PropTypes.bool,
211 | scaffoldBlockPxWidth: PropTypes.number.isRequired,
212 | toggleChildrenVisibility: PropTypes.func,
213 | buttons: PropTypes.arrayOf(PropTypes.node),
214 | className: PropTypes.string,
215 | style: PropTypes.shape({}),
216 |
217 | // Drag and drop API functions
218 | // Drag source
219 | connectDragPreview: PropTypes.func.isRequired,
220 | connectDragSource: PropTypes.func.isRequired,
221 | parentNode: PropTypes.shape({}), // Needed for dndManager
222 | isDragging: PropTypes.bool.isRequired,
223 | didDrop: PropTypes.bool.isRequired,
224 | draggedNode: PropTypes.shape({}),
225 | // Drop target
226 | isOver: PropTypes.bool.isRequired,
227 | canDrop: PropTypes.bool,
228 |
229 | // rtl support
230 | rowDirection: PropTypes.string,
231 | };
232 |
233 | export default NodeRendererDefault;
234 |
--------------------------------------------------------------------------------
/src/placeholder-renderer-default.css:
--------------------------------------------------------------------------------
1 | .rst__placeholder {
2 | position: relative;
3 | height: 68px;
4 | max-width: 300px;
5 | padding: 10px;
6 | }
7 | .rst__placeholder,
8 | .rst__placeholder > * {
9 | box-sizing: border-box;
10 | }
11 | .rst__placeholder::before {
12 | border: 3px dashed #d9d9d9;
13 | content: '';
14 | position: absolute;
15 | top: 5px;
16 | right: 5px;
17 | bottom: 5px;
18 | left: 5px;
19 | z-index: -1;
20 | }
21 |
22 | /**
23 | * The outline of where the element will go if dropped, displayed while dragging
24 | */
25 | .rst__placeholderLandingPad,
26 | .rst__placeholderCancelPad {
27 | border: none !important;
28 | box-shadow: none !important;
29 | outline: none !important;
30 | }
31 | .rst__placeholderLandingPad *,
32 | .rst__placeholderCancelPad * {
33 | opacity: 0 !important;
34 | }
35 | .rst__placeholderLandingPad::before,
36 | .rst__placeholderCancelPad::before {
37 | background-color: lightblue;
38 | border-color: white;
39 | }
40 |
41 | /**
42 | * Alternate appearance of the landing pad when the dragged location is invalid
43 | */
44 | .rst__placeholderCancelPad::before {
45 | background-color: #e6a8ad;
46 | }
47 |
--------------------------------------------------------------------------------
/src/placeholder-renderer-default.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import classnames from './utils/classnames';
4 | import './placeholder-renderer-default.css';
5 |
6 | const PlaceholderRendererDefault = ({ isOver, canDrop }) => (
7 |
14 | );
15 |
16 | PlaceholderRendererDefault.defaultProps = {
17 | isOver: false,
18 | canDrop: false,
19 | };
20 |
21 | PlaceholderRendererDefault.propTypes = {
22 | isOver: PropTypes.bool,
23 | canDrop: PropTypes.bool,
24 | };
25 |
26 | export default PlaceholderRendererDefault;
27 |
--------------------------------------------------------------------------------
/src/react-sortable-tree.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Extra class applied to VirtualScroll through className prop
3 | */
4 | .rst__virtualScrollOverride {
5 | overflow: auto !important;
6 | }
7 | .rst__virtualScrollOverride * {
8 | box-sizing: border-box;
9 | }
10 |
11 | .ReactVirtualized__Grid__innerScrollContainer {
12 | overflow: visible !important;
13 | }
14 |
15 | .rst__rtl .ReactVirtualized__Grid__innerScrollContainer {
16 | direction: rtl;
17 | }
18 |
19 | .ReactVirtualized__Grid {
20 | outline: none;
21 | }
22 |
--------------------------------------------------------------------------------
/src/react-sortable-tree.js:
--------------------------------------------------------------------------------
1 | import withScrolling, {
2 | createHorizontalStrength,
3 | createScrollingComponent,
4 | createVerticalStrength,
5 | } from 'frontend-collective-react-dnd-scrollzone';
6 | import isEqual from 'lodash.isequal';
7 | import PropTypes from 'prop-types';
8 | import React, { Component } from 'react';
9 | import { DndContext, DndProvider } from 'react-dnd';
10 | import { HTML5Backend } from 'react-dnd-html5-backend';
11 | import { polyfill } from 'react-lifecycles-compat';
12 | import { AutoSizer, List } from 'react-virtualized';
13 | import 'react-virtualized/styles.css';
14 | import NodeRendererDefault from './node-renderer-default';
15 | import PlaceholderRendererDefault from './placeholder-renderer-default';
16 | import './react-sortable-tree.css';
17 | import TreeNode from './tree-node';
18 | import TreePlaceholder from './tree-placeholder';
19 | import classnames from './utils/classnames';
20 | import {
21 | defaultGetNodeKey,
22 | defaultSearchMethod,
23 | } from './utils/default-handlers';
24 | import DndManager from './utils/dnd-manager';
25 | import { slideRows } from './utils/generic-utils';
26 | import {
27 | memoizedGetDescendantCount,
28 | memoizedGetFlatDataFromTree,
29 | memoizedInsertNode,
30 | } from './utils/memoized-tree-data-utils';
31 | import {
32 | changeNodeAtPath,
33 | find,
34 | insertNode,
35 | removeNode,
36 | toggleExpandedForAll,
37 | walk,
38 | } from './utils/tree-data-utils';
39 |
40 | let treeIdCounter = 1;
41 |
42 | const mergeTheme = props => {
43 | const merged = {
44 | ...props,
45 | style: { ...props.theme.style, ...props.style },
46 | innerStyle: { ...props.theme.innerStyle, ...props.innerStyle },
47 | reactVirtualizedListProps: {
48 | ...props.theme.reactVirtualizedListProps,
49 | ...props.reactVirtualizedListProps,
50 | },
51 | };
52 |
53 | const overridableDefaults = {
54 | nodeContentRenderer: NodeRendererDefault,
55 | placeholderRenderer: PlaceholderRendererDefault,
56 | rowHeight: 62,
57 | scaffoldBlockPxWidth: 44,
58 | slideRegionSize: 100,
59 | treeNodeRenderer: TreeNode,
60 | };
61 | Object.keys(overridableDefaults).forEach(propKey => {
62 | // If prop has been specified, do not change it
63 | // If prop is specified in theme, use the theme setting
64 | // If all else fails, fall back to the default
65 | if (props[propKey] === null) {
66 | merged[propKey] =
67 | typeof props.theme[propKey] !== 'undefined'
68 | ? props.theme[propKey]
69 | : overridableDefaults[propKey];
70 | }
71 | });
72 |
73 | return merged;
74 | };
75 |
76 | class ReactSortableTree extends Component {
77 | constructor(props) {
78 | super(props);
79 |
80 | const {
81 | dndType,
82 | nodeContentRenderer,
83 | treeNodeRenderer,
84 | isVirtualized,
85 | slideRegionSize,
86 | } = mergeTheme(props);
87 |
88 | this.dndManager = new DndManager(this);
89 |
90 | // Wrapping classes for use with react-dnd
91 | this.treeId = `rst__${treeIdCounter}`;
92 | treeIdCounter += 1;
93 | this.dndType = dndType || this.treeId;
94 | this.nodeContentRenderer = this.dndManager.wrapSource(nodeContentRenderer);
95 | this.treePlaceholderRenderer = this.dndManager.wrapPlaceholder(
96 | TreePlaceholder
97 | );
98 | this.treeNodeRenderer = this.dndManager.wrapTarget(treeNodeRenderer);
99 |
100 | // Prepare scroll-on-drag options for this list
101 | if (isVirtualized) {
102 | this.scrollZoneVirtualList = (createScrollingComponent || withScrolling)(
103 | List
104 | );
105 | this.vStrength = createVerticalStrength(slideRegionSize);
106 | this.hStrength = createHorizontalStrength(slideRegionSize);
107 | }
108 |
109 | this.state = {
110 | draggingTreeData: null,
111 | draggedNode: null,
112 | draggedMinimumTreeIndex: null,
113 | draggedDepth: null,
114 | searchMatches: [],
115 | searchFocusTreeIndex: null,
116 | dragging: false,
117 |
118 | // props that need to be used in gDSFP or static functions will be stored here
119 | instanceProps: {
120 | treeData: [],
121 | ignoreOneTreeUpdate: false,
122 | searchQuery: null,
123 | searchFocusOffset: null,
124 | },
125 | };
126 |
127 | this.toggleChildrenVisibility = this.toggleChildrenVisibility.bind(this);
128 | this.moveNode = this.moveNode.bind(this);
129 | this.startDrag = this.startDrag.bind(this);
130 | this.dragHover = this.dragHover.bind(this);
131 | this.endDrag = this.endDrag.bind(this);
132 | this.drop = this.drop.bind(this);
133 | this.handleDndMonitorChange = this.handleDndMonitorChange.bind(this);
134 | }
135 |
136 | componentDidMount() {
137 | ReactSortableTree.loadLazyChildren(this.props, this.state);
138 | const stateUpdate = ReactSortableTree.search(
139 | this.props,
140 | this.state,
141 | true,
142 | true,
143 | false
144 | );
145 | this.setState(stateUpdate);
146 |
147 | // Hook into react-dnd state changes to detect when the drag ends
148 | // TODO: This is very brittle, so it needs to be replaced if react-dnd
149 | // offers a more official way to detect when a drag ends
150 | this.clearMonitorSubscription = this.props.dragDropManager
151 | .getMonitor()
152 | .subscribeToStateChange(this.handleDndMonitorChange);
153 | }
154 |
155 | static getDerivedStateFromProps(nextProps, prevState) {
156 | const { instanceProps } = prevState;
157 | const newState = {};
158 |
159 | const isTreeDataEqual = isEqual(instanceProps.treeData, nextProps.treeData);
160 |
161 | // make sure we have the most recent version of treeData
162 | instanceProps.treeData = nextProps.treeData;
163 |
164 | if (!isTreeDataEqual) {
165 | if (instanceProps.ignoreOneTreeUpdate) {
166 | instanceProps.ignoreOneTreeUpdate = false;
167 | } else {
168 | newState.searchFocusTreeIndex = null;
169 | ReactSortableTree.loadLazyChildren(nextProps, prevState);
170 | Object.assign(
171 | newState,
172 | ReactSortableTree.search(nextProps, prevState, false, false, false)
173 | );
174 | }
175 |
176 | newState.draggingTreeData = null;
177 | newState.draggedNode = null;
178 | newState.draggedMinimumTreeIndex = null;
179 | newState.draggedDepth = null;
180 | newState.dragging = false;
181 | } else if (!isEqual(instanceProps.searchQuery, nextProps.searchQuery)) {
182 | Object.assign(
183 | newState,
184 | ReactSortableTree.search(nextProps, prevState, true, true, false)
185 | );
186 | } else if (
187 | instanceProps.searchFocusOffset !== nextProps.searchFocusOffset
188 | ) {
189 | Object.assign(
190 | newState,
191 | ReactSortableTree.search(nextProps, prevState, true, true, true)
192 | );
193 | }
194 |
195 | instanceProps.searchQuery = nextProps.searchQuery;
196 | instanceProps.searchFocusOffset = nextProps.searchFocusOffset;
197 | newState.instanceProps = {...instanceProps, ...newState.instanceProps };
198 |
199 | return newState;
200 | }
201 |
202 | // listen to dragging
203 | componentDidUpdate(prevProps, prevState) {
204 | // if it is not the same then call the onDragStateChanged
205 | if (this.state.dragging !== prevState.dragging) {
206 | if (this.props.onDragStateChanged) {
207 | this.props.onDragStateChanged({
208 | isDragging: this.state.dragging,
209 | draggedNode: this.state.draggedNode,
210 | });
211 | }
212 | }
213 | }
214 |
215 | componentWillUnmount() {
216 | this.clearMonitorSubscription();
217 | }
218 |
219 | getRows(treeData) {
220 | return memoizedGetFlatDataFromTree({
221 | ignoreCollapsed: true,
222 | getNodeKey: this.props.getNodeKey,
223 | treeData,
224 | });
225 | }
226 |
227 | handleDndMonitorChange() {
228 | const monitor = this.props.dragDropManager.getMonitor();
229 | // If the drag ends and the tree is still in a mid-drag state,
230 | // it means that the drag was canceled or the dragSource dropped
231 | // elsewhere, and we should reset the state of this tree
232 | if (!monitor.isDragging() && this.state.draggingTreeData) {
233 | setTimeout(() => {this.endDrag()});
234 | }
235 | }
236 |
237 | toggleChildrenVisibility({ node: targetNode, path }) {
238 | const { instanceProps } = this.state;
239 |
240 | const treeData = changeNodeAtPath({
241 | treeData: instanceProps.treeData,
242 | path,
243 | newNode: ({ node }) => ({ ...node, expanded: !node.expanded }),
244 | getNodeKey: this.props.getNodeKey,
245 | });
246 |
247 | this.props.onChange(treeData);
248 |
249 | this.props.onVisibilityToggle({
250 | treeData,
251 | node: targetNode,
252 | expanded: !targetNode.expanded,
253 | path,
254 | });
255 | }
256 |
257 | moveNode({
258 | node,
259 | path: prevPath,
260 | treeIndex: prevTreeIndex,
261 | depth,
262 | minimumTreeIndex,
263 | }) {
264 | const {
265 | treeData,
266 | treeIndex,
267 | path,
268 | parentNode: nextParentNode,
269 | } = insertNode({
270 | treeData: this.state.draggingTreeData,
271 | newNode: node,
272 | depth,
273 | minimumTreeIndex,
274 | expandParent: true,
275 | getNodeKey: this.props.getNodeKey,
276 | });
277 |
278 | this.props.onChange(treeData);
279 |
280 | this.props.onMoveNode({
281 | treeData,
282 | node,
283 | treeIndex,
284 | path,
285 | nextPath: path,
286 | nextTreeIndex: treeIndex,
287 | prevPath,
288 | prevTreeIndex,
289 | nextParentNode,
290 | });
291 | }
292 |
293 | // returns the new state after search
294 | static search(props, state, seekIndex, expand, singleSearch) {
295 | const {
296 | onChange,
297 | getNodeKey,
298 | searchFinishCallback,
299 | searchQuery,
300 | searchMethod,
301 | searchFocusOffset,
302 | onlyExpandSearchedNodes,
303 | } = props;
304 |
305 | const { instanceProps } = state;
306 |
307 | // Skip search if no conditions are specified
308 | if (!searchQuery && !searchMethod) {
309 | if (searchFinishCallback) {
310 | searchFinishCallback([]);
311 | }
312 |
313 | return { searchMatches: [] };
314 | }
315 |
316 | const newState = { instanceProps: {} };
317 |
318 | // if onlyExpandSearchedNodes collapse the tree and search
319 | const { treeData: expandedTreeData, matches: searchMatches } = find({
320 | getNodeKey,
321 | treeData: onlyExpandSearchedNodes
322 | ? toggleExpandedForAll({
323 | treeData: instanceProps.treeData,
324 | expanded: false,
325 | })
326 | : instanceProps.treeData,
327 | searchQuery,
328 | searchMethod: searchMethod || defaultSearchMethod,
329 | searchFocusOffset,
330 | expandAllMatchPaths: expand && !singleSearch,
331 | expandFocusMatchPaths: !!expand,
332 | });
333 |
334 | // Update the tree with data leaving all paths leading to matching nodes open
335 | if (expand) {
336 | newState.instanceProps.ignoreOneTreeUpdate = true; // Prevents infinite loop
337 | onChange(expandedTreeData);
338 | }
339 |
340 | if (searchFinishCallback) {
341 | searchFinishCallback(searchMatches);
342 | }
343 |
344 | let searchFocusTreeIndex = null;
345 | if (
346 | seekIndex &&
347 | searchFocusOffset !== null &&
348 | searchFocusOffset < searchMatches.length
349 | ) {
350 | searchFocusTreeIndex = searchMatches[searchFocusOffset].treeIndex;
351 | }
352 |
353 | newState.searchMatches = searchMatches;
354 | newState.searchFocusTreeIndex = searchFocusTreeIndex;
355 |
356 | return newState;
357 | }
358 |
359 | startDrag({ path }) {
360 | this.setState(prevState => {
361 | const {
362 | treeData: draggingTreeData,
363 | node: draggedNode,
364 | treeIndex: draggedMinimumTreeIndex,
365 | } = removeNode({
366 | treeData: prevState.instanceProps.treeData,
367 | path,
368 | getNodeKey: this.props.getNodeKey,
369 | });
370 |
371 | return {
372 | draggingTreeData,
373 | draggedNode,
374 | draggedDepth: path.length - 1,
375 | draggedMinimumTreeIndex,
376 | dragging: true,
377 | };
378 | });
379 | }
380 |
381 | dragHover({
382 | node: draggedNode,
383 | depth: draggedDepth,
384 | minimumTreeIndex: draggedMinimumTreeIndex,
385 | }) {
386 | // Ignore this hover if it is at the same position as the last hover
387 | if (
388 | this.state.draggedDepth === draggedDepth &&
389 | this.state.draggedMinimumTreeIndex === draggedMinimumTreeIndex
390 | ) {
391 | return;
392 | }
393 |
394 | this.setState(({ draggingTreeData, instanceProps }) => {
395 | // Fall back to the tree data if something is being dragged in from
396 | // an external element
397 | const newDraggingTreeData = draggingTreeData || instanceProps.treeData;
398 |
399 | const addedResult = memoizedInsertNode({
400 | treeData: newDraggingTreeData,
401 | newNode: draggedNode,
402 | depth: draggedDepth,
403 | minimumTreeIndex: draggedMinimumTreeIndex,
404 | expandParent: true,
405 | getNodeKey: this.props.getNodeKey,
406 | });
407 |
408 | const rows = this.getRows(addedResult.treeData);
409 | const expandedParentPath = rows[addedResult.treeIndex].path;
410 |
411 | return {
412 | draggedNode,
413 | draggedDepth,
414 | draggedMinimumTreeIndex,
415 | draggingTreeData: changeNodeAtPath({
416 | treeData: newDraggingTreeData,
417 | path: expandedParentPath.slice(0, -1),
418 | newNode: ({ node }) => ({ ...node, expanded: true }),
419 | getNodeKey: this.props.getNodeKey,
420 | }),
421 | // reset the scroll focus so it doesn't jump back
422 | // to a search result while dragging
423 | searchFocusTreeIndex: null,
424 | dragging: true,
425 | };
426 | });
427 | }
428 |
429 | endDrag(dropResult) {
430 | const { instanceProps } = this.state;
431 |
432 | const resetTree = () =>
433 | this.setState({
434 | draggingTreeData: null,
435 | draggedNode: null,
436 | draggedMinimumTreeIndex: null,
437 | draggedDepth: null,
438 | dragging: false,
439 | });
440 |
441 | // Drop was cancelled
442 | if (!dropResult) {
443 | resetTree();
444 | } else if (dropResult.treeId !== this.treeId) {
445 | // The node was dropped in an external drop target or tree
446 | const { node, path, treeIndex } = dropResult;
447 | let shouldCopy = this.props.shouldCopyOnOutsideDrop;
448 | if (typeof shouldCopy === 'function') {
449 | shouldCopy = shouldCopy({
450 | node,
451 | prevTreeIndex: treeIndex,
452 | prevPath: path,
453 | });
454 | }
455 |
456 | let treeData = this.state.draggingTreeData || instanceProps.treeData;
457 |
458 | // If copying is enabled, a drop outside leaves behind a copy in the
459 | // source tree
460 | if (shouldCopy) {
461 | treeData = changeNodeAtPath({
462 | treeData: instanceProps.treeData, // use treeData unaltered by the drag operation
463 | path,
464 | newNode: ({ node: copyNode }) => ({ ...copyNode }), // create a shallow copy of the node
465 | getNodeKey: this.props.getNodeKey,
466 | });
467 | }
468 |
469 | this.props.onChange(treeData);
470 |
471 | this.props.onMoveNode({
472 | treeData,
473 | node,
474 | treeIndex: null,
475 | path: null,
476 | nextPath: null,
477 | nextTreeIndex: null,
478 | prevPath: path,
479 | prevTreeIndex: treeIndex,
480 | });
481 | }
482 | }
483 |
484 | drop(dropResult) {
485 | this.moveNode(dropResult);
486 | }
487 |
488 | canNodeHaveChildren(node) {
489 | const { canNodeHaveChildren } = this.props;
490 | if (canNodeHaveChildren) {
491 | return canNodeHaveChildren(node);
492 | }
493 | return true;
494 | }
495 |
496 | // Load any children in the tree that are given by a function
497 | // calls the onChange callback on the new treeData
498 | static loadLazyChildren(props, state) {
499 | const { instanceProps } = state;
500 |
501 | walk({
502 | treeData: instanceProps.treeData,
503 | getNodeKey: props.getNodeKey,
504 | callback: ({ node, path, lowerSiblingCounts, treeIndex }) => {
505 | // If the node has children defined by a function, and is either expanded
506 | // or set to load even before expansion, run the function.
507 | if (
508 | node.children &&
509 | typeof node.children === 'function' &&
510 | (node.expanded || props.loadCollapsedLazyChildren)
511 | ) {
512 | // Call the children fetching function
513 | node.children({
514 | node,
515 | path,
516 | lowerSiblingCounts,
517 | treeIndex,
518 |
519 | // Provide a helper to append the new data when it is received
520 | done: childrenArray =>
521 | props.onChange(
522 | changeNodeAtPath({
523 | treeData: instanceProps.treeData,
524 | path,
525 | newNode: ({ node: oldNode }) =>
526 | // Only replace the old node if it's the one we set off to find children
527 | // for in the first place
528 | oldNode === node
529 | ? {
530 | ...oldNode,
531 | children: childrenArray,
532 | }
533 | : oldNode,
534 | getNodeKey: props.getNodeKey,
535 | })
536 | ),
537 | });
538 | }
539 | },
540 | });
541 | }
542 |
543 | renderRow(
544 | row,
545 | { listIndex, style, getPrevRow, matchKeys, swapFrom, swapDepth, swapLength }
546 | ) {
547 | const { node, parentNode, path, lowerSiblingCounts, treeIndex } = row;
548 |
549 | const {
550 | canDrag,
551 | generateNodeProps,
552 | scaffoldBlockPxWidth,
553 | searchFocusOffset,
554 | rowDirection,
555 | } = mergeTheme(this.props);
556 | const TreeNodeRenderer = this.treeNodeRenderer;
557 | const NodeContentRenderer = this.nodeContentRenderer;
558 | const nodeKey = path[path.length - 1];
559 | const isSearchMatch = nodeKey in matchKeys;
560 | const isSearchFocus =
561 | isSearchMatch && matchKeys[nodeKey] === searchFocusOffset;
562 | const callbackParams = {
563 | node,
564 | parentNode,
565 | path,
566 | lowerSiblingCounts,
567 | treeIndex,
568 | isSearchMatch,
569 | isSearchFocus,
570 | };
571 | const nodeProps = !generateNodeProps
572 | ? {}
573 | : generateNodeProps(callbackParams);
574 | const rowCanDrag =
575 | typeof canDrag !== 'function' ? canDrag : canDrag(callbackParams);
576 |
577 | const sharedProps = {
578 | treeIndex,
579 | scaffoldBlockPxWidth,
580 | node,
581 | path,
582 | treeId: this.treeId,
583 | rowDirection,
584 | };
585 |
586 | return (
587 |
598 |
607 |
608 | );
609 | }
610 |
611 | render() {
612 | const {
613 | dragDropManager,
614 | style,
615 | className,
616 | innerStyle,
617 | rowHeight,
618 | isVirtualized,
619 | placeholderRenderer,
620 | reactVirtualizedListProps,
621 | getNodeKey,
622 | rowDirection,
623 | } = mergeTheme(this.props);
624 | const {
625 | searchMatches,
626 | searchFocusTreeIndex,
627 | draggedNode,
628 | draggedDepth,
629 | draggedMinimumTreeIndex,
630 | instanceProps,
631 | } = this.state;
632 |
633 | const treeData = this.state.draggingTreeData || instanceProps.treeData;
634 | const rowDirectionClass = rowDirection === 'rtl' ? 'rst__rtl' : null;
635 |
636 | let rows;
637 | let swapFrom = null;
638 | let swapLength = null;
639 | if (draggedNode && draggedMinimumTreeIndex !== null) {
640 | const addedResult = memoizedInsertNode({
641 | treeData,
642 | newNode: draggedNode,
643 | depth: draggedDepth,
644 | minimumTreeIndex: draggedMinimumTreeIndex,
645 | expandParent: true,
646 | getNodeKey,
647 | });
648 |
649 | const swapTo = draggedMinimumTreeIndex;
650 | swapFrom = addedResult.treeIndex;
651 | swapLength = 1 + memoizedGetDescendantCount({ node: draggedNode });
652 | rows = slideRows(
653 | this.getRows(addedResult.treeData),
654 | swapFrom,
655 | swapTo,
656 | swapLength
657 | );
658 | } else {
659 | rows = this.getRows(treeData);
660 | }
661 |
662 | // Get indices for rows that match the search conditions
663 | const matchKeys = {};
664 | searchMatches.forEach(({ path }, i) => {
665 | matchKeys[path[path.length - 1]] = i;
666 | });
667 |
668 | // Seek to the focused search result if there is one specified
669 | const scrollToInfo =
670 | searchFocusTreeIndex !== null
671 | ? { scrollToIndex: searchFocusTreeIndex }
672 | : {};
673 |
674 | let containerStyle = style;
675 | let list;
676 | if (rows.length < 1) {
677 | const Placeholder = this.treePlaceholderRenderer;
678 | const PlaceholderContent = placeholderRenderer;
679 | list = (
680 |
681 |
682 |
683 | );
684 | } else if (isVirtualized) {
685 | containerStyle = { height: '100%', ...containerStyle };
686 |
687 | const ScrollZoneVirtualList = this.scrollZoneVirtualList;
688 | // Render list with react-virtualized
689 | list = (
690 |
691 | {({ height, width }) => (
692 | {
702 | this.scrollTop = scrollTop;
703 | }}
704 | height={height}
705 | style={innerStyle}
706 | rowCount={rows.length}
707 | estimatedRowSize={
708 | typeof rowHeight !== 'function' ? rowHeight : undefined
709 | }
710 | rowHeight={
711 | typeof rowHeight !== 'function'
712 | ? rowHeight
713 | : ({ index }) =>
714 | rowHeight({
715 | index,
716 | treeIndex: index,
717 | node: rows[index].node,
718 | path: rows[index].path,
719 | })
720 | }
721 | rowRenderer={({ index, style: rowStyle }) =>
722 | this.renderRow(rows[index], {
723 | listIndex: index,
724 | style: rowStyle,
725 | getPrevRow: () => rows[index - 1] || null,
726 | matchKeys,
727 | swapFrom,
728 | swapDepth: draggedDepth,
729 | swapLength,
730 | })
731 | }
732 | {...reactVirtualizedListProps}
733 | />
734 | )}
735 |
736 | );
737 | } else {
738 | // Render list without react-virtualized
739 | list = rows.map((row, index) =>
740 | this.renderRow(row, {
741 | listIndex: index,
742 | style: {
743 | height:
744 | typeof rowHeight !== 'function'
745 | ? rowHeight
746 | : rowHeight({
747 | index,
748 | treeIndex: index,
749 | node: row.node,
750 | path: row.path,
751 | }),
752 | },
753 | getPrevRow: () => rows[index - 1] || null,
754 | matchKeys,
755 | swapFrom,
756 | swapDepth: draggedDepth,
757 | swapLength,
758 | })
759 | );
760 | }
761 |
762 | return (
763 |
767 | {list}
768 |
769 | );
770 | }
771 | }
772 |
773 | ReactSortableTree.propTypes = {
774 | dragDropManager: PropTypes.shape({
775 | getMonitor: PropTypes.func,
776 | }).isRequired,
777 |
778 | // Tree data in the following format:
779 | // [{title: 'main', subtitle: 'sub'}, { title: 'value2', expanded: true, children: [{ title: 'value3') }] }]
780 | // `title` is the primary label for the node
781 | // `subtitle` is a secondary label for the node
782 | // `expanded` shows children of the node if true, or hides them if false. Defaults to false.
783 | // `children` is an array of child nodes belonging to the node.
784 | treeData: PropTypes.arrayOf(PropTypes.object).isRequired,
785 |
786 | // Style applied to the container wrapping the tree (style defaults to {height: '100%'})
787 | style: PropTypes.shape({}),
788 |
789 | // Class name for the container wrapping the tree
790 | className: PropTypes.string,
791 |
792 | // Style applied to the inner, scrollable container (for padding, etc.)
793 | innerStyle: PropTypes.shape({}),
794 |
795 | // Used by react-virtualized
796 | // Either a fixed row height (number) or a function that returns the
797 | // height of a row given its index: `({ index: number }): number`
798 | rowHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.func]),
799 |
800 | // Size in px of the region near the edges that initiates scrolling on dragover
801 | slideRegionSize: PropTypes.number,
802 |
803 | // Custom properties to hand to the react-virtualized list
804 | // https://github.com/bvaughn/react-virtualized/blob/master/docs/List.md#prop-types
805 | reactVirtualizedListProps: PropTypes.shape({}),
806 |
807 | // The width of the blocks containing the lines representing the structure of the tree.
808 | scaffoldBlockPxWidth: PropTypes.number,
809 |
810 | // Maximum depth nodes can be inserted at. Defaults to infinite.
811 | maxDepth: PropTypes.number,
812 |
813 | // The method used to search nodes.
814 | // Defaults to a function that uses the `searchQuery` string to search for nodes with
815 | // matching `title` or `subtitle` values.
816 | // NOTE: Changing `searchMethod` will not update the search, but changing the `searchQuery` will.
817 | searchMethod: PropTypes.func,
818 |
819 | // Used by the `searchMethod` to highlight and scroll to matched nodes.
820 | // Should be a string for the default `searchMethod`, but can be anything when using a custom search.
821 | searchQuery: PropTypes.any, // eslint-disable-line react/forbid-prop-types
822 |
823 | // Outline the <`searchFocusOffset`>th node and scroll to it.
824 | searchFocusOffset: PropTypes.number,
825 |
826 | // Get the nodes that match the search criteria. Used for counting total matches, etc.
827 | searchFinishCallback: PropTypes.func,
828 |
829 | // Generate an object with additional props to be passed to the node renderer.
830 | // Use this for adding buttons via the `buttons` key,
831 | // or additional `style` / `className` settings.
832 | generateNodeProps: PropTypes.func,
833 |
834 | // Set to false to disable virtualization.
835 | // NOTE: Auto-scrolling while dragging, and scrolling to the `searchFocusOffset` will be disabled.
836 | isVirtualized: PropTypes.bool,
837 |
838 | treeNodeRenderer: PropTypes.func,
839 |
840 | // Override the default component for rendering nodes (but keep the scaffolding generator)
841 | // This is an advanced option for complete customization of the appearance.
842 | // It is best to copy the component in `node-renderer-default.js` to use as a base, and customize as needed.
843 | nodeContentRenderer: PropTypes.func,
844 |
845 | // Override the default component for rendering an empty tree
846 | // This is an advanced option for complete customization of the appearance.
847 | // It is best to copy the component in `placeholder-renderer-default.js` to use as a base,
848 | // and customize as needed.
849 | placeholderRenderer: PropTypes.func,
850 |
851 | theme: PropTypes.shape({
852 | style: PropTypes.shape({}),
853 | innerStyle: PropTypes.shape({}),
854 | reactVirtualizedListProps: PropTypes.shape({}),
855 | scaffoldBlockPxWidth: PropTypes.number,
856 | slideRegionSize: PropTypes.number,
857 | rowHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.func]),
858 | treeNodeRenderer: PropTypes.func,
859 | nodeContentRenderer: PropTypes.func,
860 | placeholderRenderer: PropTypes.func,
861 | }),
862 |
863 | // Determine the unique key used to identify each node and
864 | // generate the `path` array passed in callbacks.
865 | // By default, returns the index in the tree (omitting hidden nodes).
866 | getNodeKey: PropTypes.func,
867 |
868 | // Called whenever tree data changed.
869 | // Just like with React input elements, you have to update your
870 | // own component's data to see the changes reflected.
871 | onChange: PropTypes.func.isRequired,
872 |
873 | // Called after node move operation.
874 | onMoveNode: PropTypes.func,
875 |
876 | // Determine whether a node can be dragged. Set to false to disable dragging on all nodes.
877 | canDrag: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
878 |
879 | // Determine whether a node can be dropped based on its path and parents'.
880 | canDrop: PropTypes.func,
881 |
882 | // Determine whether a node can have children
883 | canNodeHaveChildren: PropTypes.func,
884 |
885 | // When true, or a callback returning true, dropping nodes to react-dnd
886 | // drop targets outside of this tree will not remove them from this tree
887 | shouldCopyOnOutsideDrop: PropTypes.oneOfType([
888 | PropTypes.func,
889 | PropTypes.bool,
890 | ]),
891 |
892 | // Called after children nodes collapsed or expanded.
893 | onVisibilityToggle: PropTypes.func,
894 |
895 | dndType: PropTypes.string,
896 |
897 | // Called to track between dropped and dragging
898 | onDragStateChanged: PropTypes.func,
899 |
900 | // Specify that nodes that do not match search will be collapsed
901 | onlyExpandSearchedNodes: PropTypes.bool,
902 |
903 | // rtl support
904 | rowDirection: PropTypes.string,
905 | };
906 |
907 | ReactSortableTree.defaultProps = {
908 | canDrag: true,
909 | canDrop: null,
910 | canNodeHaveChildren: () => true,
911 | className: '',
912 | dndType: null,
913 | generateNodeProps: null,
914 | getNodeKey: defaultGetNodeKey,
915 | innerStyle: {},
916 | isVirtualized: true,
917 | maxDepth: null,
918 | treeNodeRenderer: null,
919 | nodeContentRenderer: null,
920 | onMoveNode: () => {},
921 | onVisibilityToggle: () => {},
922 | placeholderRenderer: null,
923 | reactVirtualizedListProps: {},
924 | rowHeight: null,
925 | scaffoldBlockPxWidth: null,
926 | searchFinishCallback: null,
927 | searchFocusOffset: null,
928 | searchMethod: null,
929 | searchQuery: null,
930 | shouldCopyOnOutsideDrop: false,
931 | slideRegionSize: null,
932 | style: {},
933 | theme: {},
934 | onDragStateChanged: () => {},
935 | onlyExpandSearchedNodes: false,
936 | rowDirection: 'ltr',
937 | };
938 |
939 | polyfill(ReactSortableTree);
940 |
941 | const SortableTreeWithoutDndContext = props => (
942 |
943 | {({ dragDropManager }) =>
944 | dragDropManager === undefined ? null : (
945 |
946 | )
947 | }
948 |
949 | );
950 |
951 | const SortableTree = props => (
952 |
953 |
954 |
955 | );
956 |
957 | // Export the tree component without the react-dnd DragDropContext,
958 | // for when component is used with other components using react-dnd.
959 | // see: https://github.com/gaearon/react-dnd/issues/186
960 | export { SortableTreeWithoutDndContext };
961 |
962 | export default SortableTree;
963 |
--------------------------------------------------------------------------------
/src/react-sortable-tree.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-multi-comp */
2 | import React, { Component } from 'react';
3 | import PropTypes from 'prop-types';
4 | import renderer from 'react-test-renderer';
5 | import { mount } from 'enzyme';
6 | import { List } from 'react-virtualized';
7 | import { DndProvider, DndContext } from 'react-dnd';
8 | import TestBackend from 'react-dnd-test-backend';
9 | import HTML5Backend from 'react-dnd-html5-backend';
10 | import TouchBackend from 'react-dnd-touch-backend';
11 | import SortableTree, {
12 | SortableTreeWithoutDndContext,
13 | } from './react-sortable-tree';
14 | import TreeNode from './tree-node';
15 | import DefaultNodeRenderer from './node-renderer-default';
16 |
17 | describe(' ', () => {
18 | it('should render tree correctly', () => {
19 | const tree = renderer
20 | .create( {}} />, {
21 | createNodeMock: () => ({}),
22 | })
23 | .toJSON();
24 |
25 | expect(tree).toMatchSnapshot();
26 | });
27 |
28 | it('should render nodes for flat data', () => {
29 | let wrapper;
30 |
31 | // No nodes
32 | wrapper = mount( {}} />);
33 | expect(wrapper.find(TreeNode).length).toEqual(0);
34 |
35 | // Single node
36 | wrapper = mount( {}} />);
37 | expect(wrapper.find(TreeNode).length).toEqual(1);
38 |
39 | // Two nodes
40 | wrapper = mount( {}} />);
41 | expect(wrapper.find(TreeNode).length).toEqual(2);
42 | });
43 |
44 | it('should render nodes for nested, expanded data', () => {
45 | let wrapper;
46 |
47 | // Single Nested
48 | wrapper = mount(
49 | {}}
52 | />
53 | );
54 | expect(wrapper.find(TreeNode).length).toEqual(2);
55 |
56 | // Double Nested
57 | wrapper = mount(
58 | {}}
63 | />
64 | );
65 | expect(wrapper.find(TreeNode).length).toEqual(3);
66 |
67 | // 2x Double Nested Siblings
68 | wrapper = mount(
69 | {}}
75 | />
76 | );
77 | expect(wrapper.find(TreeNode).length).toEqual(6);
78 | });
79 |
80 | it('should render nodes for nested, collapsed data', () => {
81 | let wrapper;
82 |
83 | // Single Nested
84 | wrapper = mount(
85 | {}}
88 | />
89 | );
90 | expect(wrapper.find(TreeNode).length).toEqual(1);
91 |
92 | // Double Nested
93 | wrapper = mount(
94 | {}}
99 | />
100 | );
101 | expect(wrapper.find(TreeNode).length).toEqual(1);
102 |
103 | // 2x Double Nested Siblings, top level of first expanded
104 | wrapper = mount(
105 | {}}
111 | />
112 | );
113 | expect(wrapper.find(TreeNode).length).toEqual(3);
114 | });
115 |
116 | it('should reveal hidden nodes when visibility toggled', () => {
117 | const wrapper = mount(
118 | wrapper.setProps({ treeData })}
121 | />
122 | );
123 |
124 | // Check nodes in collapsed state
125 | expect(wrapper.find(TreeNode).length).toEqual(1);
126 |
127 | // Expand node and check for the existence of the revealed child
128 | wrapper
129 | .find('.rst__expandButton')
130 | .first()
131 | .simulate('click');
132 | expect(wrapper.find(TreeNode).length).toEqual(2);
133 |
134 | // Collapse node and make sure the child has been hidden
135 | wrapper
136 | .find('.rst__collapseButton')
137 | .first()
138 | .simulate('click');
139 | expect(wrapper.find(TreeNode).length).toEqual(1);
140 | });
141 |
142 | it('should change outer wrapper style via `style` and `className` props', () => {
143 | const wrapper = mount(
144 | {}}
147 | style={{ borderWidth: 42 }}
148 | className="extra-classy"
149 | />
150 | );
151 |
152 | expect(wrapper.find('.rst__tree')).toHaveStyle('borderWidth', 42);
153 | expect(wrapper.find('.rst__tree')).toHaveClassName('extra-classy');
154 | });
155 |
156 | it('should change style of scroll container with `innerStyle` prop', () => {
157 | const wrapper = mount(
158 | {}}
161 | innerStyle={{ borderWidth: 42 }}
162 | />
163 | );
164 |
165 | expect(wrapper.find('.rst__virtualScrollOverride').first()).toHaveStyle(
166 | 'borderWidth',
167 | 42
168 | );
169 | });
170 |
171 | it('should change height according to rowHeight prop', () => {
172 | const wrapper = mount(
173 | {}}
176 | rowHeight={12}
177 | />
178 | );
179 |
180 | // Works with static value
181 | expect(wrapper.find(TreeNode).first()).toHaveStyle('height', 12);
182 |
183 | // Works with function callback
184 | wrapper.setProps({ rowHeight: ({ node }) => 42 + (node.extraHeight || 0) });
185 | expect(wrapper.find(TreeNode).first()).toHaveStyle('height', 42);
186 | expect(wrapper.find(TreeNode).last()).toHaveStyle('height', 44);
187 | });
188 |
189 | it('should toggle virtualization according to isVirtualized prop', () => {
190 | const virtualized = mount(
191 | {}}
194 | isVirtualized
195 | />
196 | );
197 |
198 | expect(virtualized.find(List).length).toEqual(1);
199 |
200 | const notVirtualized = mount(
201 | {}}
204 | isVirtualized={false}
205 | />
206 | );
207 |
208 | expect(notVirtualized.find(List).length).toEqual(0);
209 | });
210 |
211 | it('should change scaffold width according to scaffoldBlockPxWidth prop', () => {
212 | const wrapper = mount(
213 | {}}
216 | scaffoldBlockPxWidth={12}
217 | />
218 | );
219 |
220 | expect(wrapper.find('.rst__lineBlock')).toHaveStyle('width', 12);
221 | });
222 |
223 | it('should pass props to the node renderer from `generateNodeProps`', () => {
224 | const title = 42;
225 | const wrapper = mount(
226 | {}}
229 | generateNodeProps={({ node }) => ({ buttons: [node.title] })}
230 | />
231 | );
232 |
233 | expect(wrapper.find(DefaultNodeRenderer)).toHaveProp('buttons', [title]);
234 | });
235 |
236 | it('should call the callback in `onVisibilityToggle` when visibility toggled', () => {
237 | let out = null;
238 |
239 | const wrapper = mount(
240 | wrapper.setProps({ treeData })}
243 | onVisibilityToggle={({ expanded }) => {
244 | out = expanded ? 'expanded' : 'collapsed';
245 | }}
246 | />
247 | );
248 |
249 | wrapper
250 | .find('.rst__expandButton')
251 | .first()
252 | .simulate('click');
253 | expect(out).toEqual('expanded');
254 | wrapper
255 | .find('.rst__collapseButton')
256 | .first()
257 | .simulate('click');
258 | expect(out).toEqual('collapsed');
259 | });
260 |
261 | it('should render with a custom `nodeContentRenderer`', () => {
262 | class FakeNode extends Component {
263 | render() {
264 | return {this.props.node.title}
;
265 | }
266 | }
267 | FakeNode.propTypes = {
268 | node: PropTypes.shape({ title: PropTypes.string }).isRequired,
269 | };
270 |
271 | const wrapper = mount(
272 | {}}
275 | nodeContentRenderer={FakeNode}
276 | />
277 | );
278 |
279 | expect(wrapper.find(FakeNode).length).toEqual(1);
280 | });
281 |
282 | it('search should call searchFinishCallback', () => {
283 | const searchFinishCallback = jest.fn();
284 | mount(
285 | {}}
291 | />
292 | );
293 |
294 | expect(searchFinishCallback).toHaveBeenCalledWith([
295 | // Node should be found expanded
296 | { node: { title: 'b' }, path: [0, 1], treeIndex: 1 },
297 | ]);
298 | });
299 |
300 | it('search should expand all matches and seek out the focus offset', () => {
301 | const wrapper = mount(
302 | {}}
309 | />
310 | );
311 |
312 | const tree = wrapper.find('ReactSortableTree').instance();
313 | expect(tree.state.searchMatches).toEqual([
314 | { node: { title: 'b' }, path: [0, 1], treeIndex: 1 },
315 | { node: { title: 'be' }, path: [2, 3], treeIndex: 3 },
316 | ]);
317 | expect(tree.state.searchFocusTreeIndex).toEqual(null);
318 |
319 | wrapper.setProps({ searchFocusOffset: 0 });
320 | expect(tree.state.searchFocusTreeIndex).toEqual(1);
321 |
322 | wrapper.setProps({ searchFocusOffset: 1 });
323 | // As the empty `onChange` we use here doesn't actually change
324 | // the tree, the expansion of all nodes doesn't get preserved
325 | // after the first mount, and this change in searchFocusOffset
326 | // only triggers the opening of a single path.
327 | // Therefore it's 2 instead of 3.
328 | expect(tree.state.searchFocusTreeIndex).toEqual(2);
329 | });
330 |
331 | it('search onlyExpandSearchedNodes should collapse all nodes except matches', () => {
332 | const wrapper = mount(
333 | wrapper.setProps({ treeData })}
349 | onlyExpandSearchedNodes
350 | />
351 | );
352 | wrapper.setProps({ searchQuery: 'be' });
353 | expect(wrapper.prop('treeData')).toEqual([
354 | {
355 | title: 'a',
356 | children: [
357 | {
358 | title: 'b',
359 | children: [
360 | {
361 | title: 'c',
362 | expanded: false,
363 | },
364 | ],
365 | expanded: false,
366 | },
367 | ],
368 | expanded: false,
369 | },
370 | {
371 | title: 'b',
372 | children: [
373 | {
374 | title: 'd',
375 | children: [
376 | {
377 | title: 'be',
378 | expanded: false,
379 | },
380 | ],
381 | expanded: true,
382 | },
383 | ],
384 | expanded: true,
385 | },
386 | {
387 | title: 'c',
388 | children: [
389 | {
390 | title: 'f',
391 | children: [
392 | {
393 | title: 'dd',
394 | expanded: false,
395 | },
396 | ],
397 | expanded: false,
398 | },
399 | ],
400 | expanded: false,
401 | },
402 | ]);
403 | });
404 |
405 | it('loads using SortableTreeWithoutDndContext', () => {
406 | expect(
407 | mount(
408 |
409 | {}}
412 | />
413 |
414 | )
415 | ).toBeDefined();
416 | expect(
417 | mount(
418 |
419 | {}}
422 | />
423 |
424 | )
425 | ).toBeDefined();
426 | });
427 |
428 | it('loads using SortableTreeWithoutDndContext', () => {
429 | const onDragStateChanged = jest.fn();
430 | const treeData = [{ title: 'a' }, { title: 'b' }];
431 | let manager = null;
432 |
433 | const wrapper = mount(
434 |
435 |
436 | {({ dragDropManager }) => {
437 | manager = dragDropManager;
438 | }}
439 |
440 | {}}
444 | />
445 |
446 | );
447 |
448 | // Obtain a reference to the backend
449 | const backend = manager.getBackend();
450 |
451 | // Retrieve our DnD-wrapped node component type
452 | const wrappedNodeType = wrapper.find('ReactSortableTree').instance()
453 | .nodeContentRenderer;
454 |
455 | // And get the first such component
456 | const nodeInstance = wrapper
457 | .find(wrappedNodeType)
458 | .first()
459 | .instance();
460 |
461 | backend.simulateBeginDrag([nodeInstance.getHandlerId()]);
462 |
463 | expect(onDragStateChanged).toHaveBeenCalledWith({
464 | isDragging: true,
465 | draggedNode: treeData[0],
466 | });
467 |
468 | backend.simulateEndDrag([nodeInstance.getHandlerId()]);
469 |
470 | expect(onDragStateChanged).toHaveBeenCalledWith({
471 | isDragging: false,
472 | draggedNode: null,
473 | });
474 | expect(onDragStateChanged).toHaveBeenCalledTimes(2);
475 | });
476 | });
477 |
--------------------------------------------------------------------------------
/src/tests.js:
--------------------------------------------------------------------------------
1 | // Reference: https://github.com/webpack/karma-webpack#alternative-usage
2 | const tests = require.context('.', true, /\.test\.(js|jsx)$/);
3 | tests.keys().forEach(tests);
4 |
--------------------------------------------------------------------------------
/src/tree-node.css:
--------------------------------------------------------------------------------
1 | .rst__node {
2 | min-width: 100%;
3 | white-space: nowrap;
4 | position: relative;
5 | text-align: left;
6 | }
7 |
8 | .rst__node.rst__rtl {
9 | text-align: right;
10 | }
11 |
12 | .rst__nodeContent {
13 | position: absolute;
14 | top: 0;
15 | bottom: 0;
16 | }
17 |
18 | /* ==========================================================================
19 | Scaffold
20 |
21 | Line-overlaid blocks used for showing the tree structure
22 | ========================================================================== */
23 | .rst__lineBlock,
24 | .rst__absoluteLineBlock {
25 | height: 100%;
26 | position: relative;
27 | display: inline-block;
28 | }
29 |
30 | .rst__absoluteLineBlock {
31 | position: absolute;
32 | top: 0;
33 | }
34 |
35 | .rst__lineHalfHorizontalRight::before,
36 | .rst__lineFullVertical::after,
37 | .rst__lineHalfVerticalTop::after,
38 | .rst__lineHalfVerticalBottom::after {
39 | position: absolute;
40 | content: '';
41 | background-color: black;
42 | }
43 |
44 | /**
45 | * +-----+
46 | * | |
47 | * | +--+
48 | * | |
49 | * +-----+
50 | */
51 | .rst__lineHalfHorizontalRight::before {
52 | height: 1px;
53 | top: 50%;
54 | right: 0;
55 | width: 50%;
56 | }
57 |
58 | .rst__rtl.rst__lineHalfHorizontalRight::before {
59 | left: 0;
60 | right: initial;
61 | }
62 |
63 | /**
64 | * +--+--+
65 | * | | |
66 | * | | |
67 | * | | |
68 | * +--+--+
69 | */
70 | .rst__lineFullVertical::after,
71 | .rst__lineHalfVerticalTop::after,
72 | .rst__lineHalfVerticalBottom::after {
73 | width: 1px;
74 | left: 50%;
75 | top: 0;
76 | height: 100%;
77 | }
78 |
79 | /**
80 | * +--+--+
81 | * | | |
82 | * | | |
83 | * | | |
84 | * +--+--+
85 | */
86 | .rst__rtl.rst__lineFullVertical::after,
87 | .rst__rtl.rst__lineHalfVerticalTop::after,
88 | .rst__rtl.rst__lineHalfVerticalBottom::after {
89 | right: 50%;
90 | left: initial;
91 | }
92 |
93 | /**
94 | * +-----+
95 | * | | |
96 | * | + |
97 | * | |
98 | * +-----+
99 | */
100 | .rst__lineHalfVerticalTop::after {
101 | height: 50%;
102 | }
103 |
104 | /**
105 | * +-----+
106 | * | |
107 | * | + |
108 | * | | |
109 | * +-----+
110 | */
111 | .rst__lineHalfVerticalBottom::after {
112 | top: auto;
113 | bottom: 0;
114 | height: 50%;
115 | }
116 |
117 | /* Highlight line for pointing to dragged row destination
118 | ========================================================================== */
119 | /**
120 | * +--+--+
121 | * | | |
122 | * | | |
123 | * | | |
124 | * +--+--+
125 | */
126 | .rst__highlightLineVertical {
127 | z-index: 3;
128 | }
129 | .rst__highlightLineVertical::before {
130 | position: absolute;
131 | content: '';
132 | background-color: #36c2f6;
133 | width: 8px;
134 | margin-left: -4px;
135 | left: 50%;
136 | top: 0;
137 | height: 100%;
138 | }
139 |
140 | .rst__rtl.rst__highlightLineVertical::before {
141 | margin-left: initial;
142 | margin-right: -4px;
143 | left: initial;
144 | right: 50%;
145 | }
146 |
147 | @keyframes arrow-pulse {
148 | 0% {
149 | transform: translate(0, 0);
150 | opacity: 0;
151 | }
152 | 30% {
153 | transform: translate(0, 300%);
154 | opacity: 1;
155 | }
156 | 70% {
157 | transform: translate(0, 700%);
158 | opacity: 1;
159 | }
160 | 100% {
161 | transform: translate(0, 1000%);
162 | opacity: 0;
163 | }
164 | }
165 | .rst__highlightLineVertical::after {
166 | content: '';
167 | position: absolute;
168 | height: 0;
169 | margin-left: -4px;
170 | left: 50%;
171 | top: 0;
172 | border-left: 4px solid transparent;
173 | border-right: 4px solid transparent;
174 | border-top: 4px solid white;
175 | animation: arrow-pulse 1s infinite linear both;
176 | }
177 |
178 | .rst__rtl.rst__highlightLineVertical::after {
179 | margin-left: initial;
180 | margin-right: -4px;
181 | right: 50%;
182 | left: initial;
183 | }
184 |
185 | /**
186 | * +-----+
187 | * | |
188 | * | +--+
189 | * | | |
190 | * +--+--+
191 | */
192 | .rst__highlightTopLeftCorner::before {
193 | z-index: 3;
194 | content: '';
195 | position: absolute;
196 | border-top: solid 8px #36c2f6;
197 | border-left: solid 8px #36c2f6;
198 | box-sizing: border-box;
199 | height: calc(50% + 4px);
200 | top: 50%;
201 | margin-top: -4px;
202 | right: 0;
203 | width: calc(50% + 4px);
204 | }
205 |
206 | .rst__rtl.rst__highlightTopLeftCorner::before {
207 | border-right: solid 8px #36c2f6;
208 | border-left: none;
209 | left: 0;
210 | right: initial;
211 | }
212 |
213 | /**
214 | * +--+--+
215 | * | | |
216 | * | | |
217 | * | +->|
218 | * +-----+
219 | */
220 | .rst__highlightBottomLeftCorner {
221 | z-index: 3;
222 | }
223 | .rst__highlightBottomLeftCorner::before {
224 | content: '';
225 | position: absolute;
226 | border-bottom: solid 8px #36c2f6;
227 | border-left: solid 8px #36c2f6;
228 | box-sizing: border-box;
229 | height: calc(100% + 4px);
230 | top: 0;
231 | right: 12px;
232 | width: calc(50% - 8px);
233 | }
234 |
235 | .rst__rtl.rst__highlightBottomLeftCorner::before {
236 | border-right: solid 8px #36c2f6;
237 | border-left: none;
238 | left: 12px;
239 | right: initial;
240 | }
241 |
242 | .rst__highlightBottomLeftCorner::after {
243 | content: '';
244 | position: absolute;
245 | height: 0;
246 | right: 0;
247 | top: 100%;
248 | margin-top: -12px;
249 | border-top: 12px solid transparent;
250 | border-bottom: 12px solid transparent;
251 | border-left: 12px solid #36c2f6;
252 | }
253 |
254 | .rst__rtl.rst__highlightBottomLeftCorner::after {
255 | left: 0;
256 | right: initial;
257 | border-right: 12px solid #36c2f6;
258 | border-left: none;
259 | }
260 |
--------------------------------------------------------------------------------
/src/tree-node.js:
--------------------------------------------------------------------------------
1 | import React, { Component, Children, cloneElement } from 'react';
2 | import PropTypes from 'prop-types';
3 | import classnames from './utils/classnames';
4 | import './tree-node.css';
5 |
6 | class TreeNode extends Component {
7 | render() {
8 | const {
9 | children,
10 | listIndex,
11 | swapFrom,
12 | swapLength,
13 | swapDepth,
14 | scaffoldBlockPxWidth,
15 | lowerSiblingCounts,
16 | connectDropTarget,
17 | isOver,
18 | draggedNode,
19 | canDrop,
20 | treeIndex,
21 | treeId, // Delete from otherProps
22 | getPrevRow, // Delete from otherProps
23 | node, // Delete from otherProps
24 | path, // Delete from otherProps
25 | rowDirection,
26 | ...otherProps
27 | } = this.props;
28 |
29 | const rowDirectionClass = rowDirection === 'rtl' ? 'rst__rtl' : null;
30 |
31 | // Construct the scaffold representing the structure of the tree
32 | const scaffoldBlockCount = lowerSiblingCounts.length;
33 | const scaffold = [];
34 | lowerSiblingCounts.forEach((lowerSiblingCount, i) => {
35 | let lineClass = '';
36 | if (lowerSiblingCount > 0) {
37 | // At this level in the tree, the nodes had sibling nodes further down
38 |
39 | if (listIndex === 0) {
40 | // Top-left corner of the tree
41 | // +-----+
42 | // | |
43 | // | +--+
44 | // | | |
45 | // +--+--+
46 | lineClass =
47 | 'rst__lineHalfHorizontalRight rst__lineHalfVerticalBottom';
48 | } else if (i === scaffoldBlockCount - 1) {
49 | // Last scaffold block in the row, right before the row content
50 | // +--+--+
51 | // | | |
52 | // | +--+
53 | // | | |
54 | // +--+--+
55 | lineClass = 'rst__lineHalfHorizontalRight rst__lineFullVertical';
56 | } else {
57 | // Simply connecting the line extending down to the next sibling on this level
58 | // +--+--+
59 | // | | |
60 | // | | |
61 | // | | |
62 | // +--+--+
63 | lineClass = 'rst__lineFullVertical';
64 | }
65 | } else if (listIndex === 0) {
66 | // Top-left corner of the tree, but has no siblings
67 | // +-----+
68 | // | |
69 | // | +--+
70 | // | |
71 | // +-----+
72 | lineClass = 'rst__lineHalfHorizontalRight';
73 | } else if (i === scaffoldBlockCount - 1) {
74 | // The last or only node in this level of the tree
75 | // +--+--+
76 | // | | |
77 | // | +--+
78 | // | |
79 | // +-----+
80 | lineClass = 'rst__lineHalfVerticalTop rst__lineHalfHorizontalRight';
81 | }
82 |
83 | scaffold.push(
84 |
89 | );
90 |
91 | if (treeIndex !== listIndex && i === swapDepth) {
92 | // This row has been shifted, and is at the depth of
93 | // the line pointing to the new destination
94 | let highlightLineClass = '';
95 |
96 | if (listIndex === swapFrom + swapLength - 1) {
97 | // This block is on the bottom (target) line
98 | // This block points at the target block (where the row will go when released)
99 | highlightLineClass = 'rst__highlightBottomLeftCorner';
100 | } else if (treeIndex === swapFrom) {
101 | // This block is on the top (source) line
102 | highlightLineClass = 'rst__highlightTopLeftCorner';
103 | } else {
104 | // This block is between the bottom and top
105 | highlightLineClass = 'rst__highlightLineVertical';
106 | }
107 |
108 | let style;
109 | if (rowDirection === 'rtl') {
110 | style = {
111 | width: scaffoldBlockPxWidth,
112 | right: scaffoldBlockPxWidth * i,
113 | };
114 | } else {
115 | // Default ltr
116 | style = {
117 | width: scaffoldBlockPxWidth,
118 | left: scaffoldBlockPxWidth * i,
119 | };
120 | }
121 |
122 | scaffold.push(
123 |
133 | );
134 | }
135 | });
136 |
137 | let style;
138 | if (rowDirection === 'rtl') {
139 | style = { right: scaffoldBlockPxWidth * scaffoldBlockCount };
140 | } else {
141 | // Default ltr
142 | style = { left: scaffoldBlockPxWidth * scaffoldBlockCount };
143 | }
144 |
145 | return connectDropTarget(
146 |
150 | {scaffold}
151 |
152 |
153 | {Children.map(children, child =>
154 | cloneElement(child, {
155 | isOver,
156 | canDrop,
157 | draggedNode,
158 | })
159 | )}
160 |
161 |
162 | );
163 | }
164 | }
165 |
166 | TreeNode.defaultProps = {
167 | swapFrom: null,
168 | swapDepth: null,
169 | swapLength: null,
170 | canDrop: false,
171 | draggedNode: null,
172 | rowDirection: 'ltr',
173 | };
174 |
175 | TreeNode.propTypes = {
176 | treeIndex: PropTypes.number.isRequired,
177 | treeId: PropTypes.string.isRequired,
178 | swapFrom: PropTypes.number,
179 | swapDepth: PropTypes.number,
180 | swapLength: PropTypes.number,
181 | scaffoldBlockPxWidth: PropTypes.number.isRequired,
182 | lowerSiblingCounts: PropTypes.arrayOf(PropTypes.number).isRequired,
183 |
184 | listIndex: PropTypes.number.isRequired,
185 | children: PropTypes.node.isRequired,
186 |
187 | // Drop target
188 | connectDropTarget: PropTypes.func.isRequired,
189 | isOver: PropTypes.bool.isRequired,
190 | canDrop: PropTypes.bool,
191 | draggedNode: PropTypes.shape({}),
192 |
193 | // used in dndManager
194 | getPrevRow: PropTypes.func.isRequired,
195 | node: PropTypes.shape({}).isRequired,
196 | path: PropTypes.arrayOf(
197 | PropTypes.oneOfType([PropTypes.string, PropTypes.number])
198 | ).isRequired,
199 |
200 | // rtl support
201 | rowDirection: PropTypes.string,
202 | };
203 |
204 | export default TreeNode;
205 |
--------------------------------------------------------------------------------
/src/tree-placeholder.js:
--------------------------------------------------------------------------------
1 | import React, { Children, cloneElement, Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | class TreePlaceholder extends Component {
5 | render() {
6 | const {
7 | children,
8 | connectDropTarget,
9 | treeId,
10 | drop,
11 | ...otherProps
12 | } = this.props;
13 | return connectDropTarget(
14 |
15 | {Children.map(children, child =>
16 | cloneElement(child, {
17 | ...otherProps,
18 | })
19 | )}
20 |
21 | );
22 | }
23 | }
24 |
25 | TreePlaceholder.defaultProps = {
26 | canDrop: false,
27 | draggedNode: null,
28 | };
29 |
30 | TreePlaceholder.propTypes = {
31 | children: PropTypes.node.isRequired,
32 |
33 | // Drop target
34 | connectDropTarget: PropTypes.func.isRequired,
35 | isOver: PropTypes.bool.isRequired,
36 | canDrop: PropTypes.bool,
37 | draggedNode: PropTypes.shape({}),
38 | treeId: PropTypes.string.isRequired,
39 | drop: PropTypes.func.isRequired,
40 | };
41 |
42 | export default TreePlaceholder;
43 |
--------------------------------------------------------------------------------
/src/utils/classnames.js:
--------------------------------------------------------------------------------
1 | // very simple className utility for creating a classname string...
2 | // Falsy arguments are ignored:
3 | //
4 | // const active = true
5 | // const className = classnames(
6 | // "class1",
7 | // !active && "class2",
8 | // active && "class3"
9 | // ); // returns -> class1 class3";
10 | //
11 | export default function classnames(...classes) {
12 | // Use Boolean constructor as a filter callback
13 | // Allows for loose type truthy/falsey checks
14 | // Boolean("") === false;
15 | // Boolean(false) === false;
16 | // Boolean(undefined) === false;
17 | // Boolean(null) === false;
18 | // Boolean(0) === false;
19 | // Boolean("classname") === true;
20 | return classes.filter(Boolean).join(' ');
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/default-handlers.js:
--------------------------------------------------------------------------------
1 | export function defaultGetNodeKey({ treeIndex }) {
2 | return treeIndex;
3 | }
4 |
5 | // Cheap hack to get the text of a react object
6 | function getReactElementText(parent) {
7 | if (typeof parent === 'string') {
8 | return parent;
9 | }
10 |
11 | if (
12 | parent === null ||
13 | typeof parent !== 'object' ||
14 | !parent.props ||
15 | !parent.props.children ||
16 | (typeof parent.props.children !== 'string' &&
17 | typeof parent.props.children !== 'object')
18 | ) {
19 | return '';
20 | }
21 |
22 | if (typeof parent.props.children === 'string') {
23 | return parent.props.children;
24 | }
25 |
26 | return parent.props.children
27 | .map(child => getReactElementText(child))
28 | .join('');
29 | }
30 |
31 | // Search for a query string inside a node property
32 | function stringSearch(key, searchQuery, node, path, treeIndex) {
33 | if (typeof node[key] === 'function') {
34 | // Search within text after calling its function to generate the text
35 | return (
36 | String(node[key]({ node, path, treeIndex })).indexOf(searchQuery) > -1
37 | );
38 | }
39 | if (typeof node[key] === 'object') {
40 | // Search within text inside react elements
41 | return getReactElementText(node[key]).indexOf(searchQuery) > -1;
42 | }
43 |
44 | // Search within string
45 | return node[key] && String(node[key]).indexOf(searchQuery) > -1;
46 | }
47 |
48 | export function defaultSearchMethod({ node, path, treeIndex, searchQuery }) {
49 | return (
50 | stringSearch('title', searchQuery, node, path, treeIndex) ||
51 | stringSearch('subtitle', searchQuery, node, path, treeIndex)
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/utils/dnd-manager.js:
--------------------------------------------------------------------------------
1 | import { DragSource as dragSource, DropTarget as dropTarget } from 'react-dnd';
2 | import { findDOMNode } from 'react-dom';
3 | import { getDepth } from './tree-data-utils';
4 | import { memoizedInsertNode } from './memoized-tree-data-utils';
5 |
6 | export default class DndManager {
7 | constructor(treeRef) {
8 | this.treeRef = treeRef;
9 | }
10 |
11 | get startDrag() {
12 | return this.treeRef.startDrag;
13 | }
14 |
15 | get dragHover() {
16 | return this.treeRef.dragHover;
17 | }
18 |
19 | get endDrag() {
20 | return this.treeRef.endDrag;
21 | }
22 |
23 | get drop() {
24 | return this.treeRef.drop;
25 | }
26 |
27 | get treeId() {
28 | return this.treeRef.treeId;
29 | }
30 |
31 | get dndType() {
32 | return this.treeRef.dndType;
33 | }
34 |
35 | get treeData() {
36 | return this.treeRef.state.draggingTreeData || this.treeRef.props.treeData;
37 | }
38 |
39 | get getNodeKey() {
40 | return this.treeRef.props.getNodeKey;
41 | }
42 |
43 | get customCanDrop() {
44 | return this.treeRef.props.canDrop;
45 | }
46 |
47 | get maxDepth() {
48 | return this.treeRef.props.maxDepth;
49 | }
50 |
51 | getTargetDepth(dropTargetProps, monitor, component) {
52 | let dropTargetDepth = 0;
53 |
54 | const rowAbove = dropTargetProps.getPrevRow();
55 | if (rowAbove) {
56 | let { path } = rowAbove;
57 | const aboveNodeCannotHaveChildren = !this.treeRef.canNodeHaveChildren(
58 | rowAbove.node
59 | );
60 | if (aboveNodeCannotHaveChildren) {
61 | path = path.slice(0, path.length - 1);
62 | }
63 |
64 | // Limit the length of the path to the deepest possible
65 | dropTargetDepth = Math.min(path.length, dropTargetProps.path.length);
66 | }
67 |
68 | let blocksOffset;
69 | let dragSourceInitialDepth = (monitor.getItem().path || []).length;
70 |
71 | // When adding node from external source
72 | if (monitor.getItem().treeId !== this.treeId) {
73 | // Ignore the tree depth of the source, if it had any to begin with
74 | dragSourceInitialDepth = 0;
75 |
76 | if (component) {
77 | const relativePosition = findDOMNode(component).getBoundingClientRect(); // eslint-disable-line react/no-find-dom-node
78 | const leftShift =
79 | monitor.getSourceClientOffset().x - relativePosition.left;
80 | blocksOffset = Math.round(
81 | leftShift / dropTargetProps.scaffoldBlockPxWidth
82 | );
83 | } else {
84 | blocksOffset = dropTargetProps.path.length;
85 | }
86 | } else {
87 | // handle row direction support
88 | const direction = dropTargetProps.rowDirection === 'rtl' ? -1 : 1;
89 |
90 | blocksOffset = Math.round(
91 | (direction * monitor.getDifferenceFromInitialOffset().x) /
92 | dropTargetProps.scaffoldBlockPxWidth
93 | );
94 | }
95 |
96 | let targetDepth = Math.min(
97 | dropTargetDepth,
98 | Math.max(0, dragSourceInitialDepth + blocksOffset - 1)
99 | );
100 |
101 | // If a maxDepth is defined, constrain the target depth
102 | if (typeof this.maxDepth !== 'undefined' && this.maxDepth !== null) {
103 | const draggedNode = monitor.getItem().node;
104 | const draggedChildDepth = getDepth(draggedNode);
105 |
106 | targetDepth = Math.max(
107 | 0,
108 | Math.min(targetDepth, this.maxDepth - draggedChildDepth - 1)
109 | );
110 | }
111 |
112 | return targetDepth;
113 | }
114 |
115 | canDrop(dropTargetProps, monitor) {
116 | if (!monitor.isOver()) {
117 | return false;
118 | }
119 |
120 | const rowAbove = dropTargetProps.getPrevRow();
121 | const abovePath = rowAbove ? rowAbove.path : [];
122 | const aboveNode = rowAbove ? rowAbove.node : {};
123 | const targetDepth = this.getTargetDepth(dropTargetProps, monitor, null);
124 |
125 | // Cannot drop if we're adding to the children of the row above and
126 | // the row above is a function
127 | if (
128 | targetDepth >= abovePath.length &&
129 | typeof aboveNode.children === 'function'
130 | ) {
131 | return false;
132 | }
133 |
134 | if (typeof this.customCanDrop === 'function') {
135 | const { node } = monitor.getItem();
136 | const addedResult = memoizedInsertNode({
137 | treeData: this.treeData,
138 | newNode: node,
139 | depth: targetDepth,
140 | getNodeKey: this.getNodeKey,
141 | minimumTreeIndex: dropTargetProps.listIndex,
142 | expandParent: true,
143 | });
144 |
145 | return this.customCanDrop({
146 | node,
147 | prevPath: monitor.getItem().path,
148 | prevParent: monitor.getItem().parentNode,
149 | prevTreeIndex: monitor.getItem().treeIndex, // Equals -1 when dragged from external tree
150 | nextPath: addedResult.path,
151 | nextParent: addedResult.parentNode,
152 | nextTreeIndex: addedResult.treeIndex,
153 | });
154 | }
155 |
156 | return true;
157 | }
158 |
159 | wrapSource(el) {
160 | const nodeDragSource = {
161 | beginDrag: props => {
162 | this.startDrag(props);
163 |
164 | return {
165 | node: props.node,
166 | parentNode: props.parentNode,
167 | path: props.path,
168 | treeIndex: props.treeIndex,
169 | treeId: props.treeId,
170 | };
171 | },
172 |
173 | endDrag: (props, monitor) => {
174 | this.endDrag(monitor.getDropResult());
175 | },
176 |
177 | isDragging: (props, monitor) => {
178 | const dropTargetNode = monitor.getItem().node;
179 | const draggedNode = props.node;
180 |
181 | return draggedNode === dropTargetNode;
182 | },
183 | };
184 |
185 | function nodeDragSourcePropInjection(connect, monitor) {
186 | return {
187 | connectDragSource: connect.dragSource(),
188 | connectDragPreview: connect.dragPreview(),
189 | isDragging: monitor.isDragging(),
190 | didDrop: monitor.didDrop(),
191 | };
192 | }
193 |
194 | return dragSource(
195 | this.dndType,
196 | nodeDragSource,
197 | nodeDragSourcePropInjection
198 | )(el);
199 | }
200 |
201 | wrapTarget(el) {
202 | const nodeDropTarget = {
203 | drop: (dropTargetProps, monitor, component) => {
204 | const result = {
205 | node: monitor.getItem().node,
206 | path: monitor.getItem().path,
207 | treeIndex: monitor.getItem().treeIndex,
208 | treeId: this.treeId,
209 | minimumTreeIndex: dropTargetProps.treeIndex,
210 | depth: this.getTargetDepth(dropTargetProps, monitor, component),
211 | };
212 |
213 | this.drop(result);
214 |
215 | return result;
216 | },
217 |
218 | hover: (dropTargetProps, monitor, component) => {
219 | const targetDepth = this.getTargetDepth(
220 | dropTargetProps,
221 | monitor,
222 | component
223 | );
224 | const draggedNode = monitor.getItem().node;
225 | const needsRedraw =
226 | // Redraw if hovered above different nodes
227 | dropTargetProps.node !== draggedNode ||
228 | // Or hovered above the same node but at a different depth
229 | targetDepth !== dropTargetProps.path.length - 1;
230 |
231 | if (!needsRedraw) {
232 | return;
233 | }
234 |
235 | // throttle `dragHover` work to available animation frames
236 | cancelAnimationFrame(this.rafId);
237 | this.rafId = requestAnimationFrame(() => {
238 | this.dragHover({
239 | node: draggedNode,
240 | path: monitor.getItem().path,
241 | minimumTreeIndex: dropTargetProps.listIndex,
242 | depth: targetDepth,
243 | });
244 | });
245 | },
246 |
247 | canDrop: this.canDrop.bind(this),
248 | };
249 |
250 | function nodeDropTargetPropInjection(connect, monitor) {
251 | const dragged = monitor.getItem();
252 | return {
253 | connectDropTarget: connect.dropTarget(),
254 | isOver: monitor.isOver(),
255 | canDrop: monitor.canDrop(),
256 | draggedNode: dragged ? dragged.node : null,
257 | };
258 | }
259 |
260 | return dropTarget(
261 | this.dndType,
262 | nodeDropTarget,
263 | nodeDropTargetPropInjection
264 | )(el);
265 | }
266 |
267 | wrapPlaceholder(el) {
268 | const placeholderDropTarget = {
269 | drop: (dropTargetProps, monitor) => {
270 | const { node, path, treeIndex } = monitor.getItem();
271 | const result = {
272 | node,
273 | path,
274 | treeIndex,
275 | treeId: this.treeId,
276 | minimumTreeIndex: 0,
277 | depth: 0,
278 | };
279 |
280 | this.drop(result);
281 |
282 | return result;
283 | },
284 | };
285 |
286 | function placeholderPropInjection(connect, monitor) {
287 | const dragged = monitor.getItem();
288 | return {
289 | connectDropTarget: connect.dropTarget(),
290 | isOver: monitor.isOver(),
291 | canDrop: monitor.canDrop(),
292 | draggedNode: dragged ? dragged.node : null,
293 | };
294 | }
295 |
296 | return dropTarget(
297 | this.dndType,
298 | placeholderDropTarget,
299 | placeholderPropInjection
300 | )(el);
301 | }
302 | }
303 |
--------------------------------------------------------------------------------
/src/utils/generic-utils.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 |
3 | export function slideRows(rows, fromIndex, toIndex, count = 1) {
4 | const rowsWithoutMoved = [
5 | ...rows.slice(0, fromIndex),
6 | ...rows.slice(fromIndex + count),
7 | ];
8 |
9 | return [
10 | ...rowsWithoutMoved.slice(0, toIndex),
11 | ...rows.slice(fromIndex, fromIndex + count),
12 | ...rowsWithoutMoved.slice(toIndex),
13 | ];
14 | }
15 |
--------------------------------------------------------------------------------
/src/utils/generic-utils.test.js:
--------------------------------------------------------------------------------
1 | import { slideRows } from './generic-utils';
2 |
3 | describe('slideRows', () => {
4 | it('should handle empty slide', () => {
5 | expect(slideRows([0, 1, 2], 1, 2, 0)).toEqual([0, 1, 2]);
6 | expect(slideRows([0, 1, 2], 1, 0, 0)).toEqual([0, 1, 2]);
7 | expect(slideRows([0, 1, 2], 1, 1, 0)).toEqual([0, 1, 2]);
8 | });
9 |
10 | it('should handle single slides', () => {
11 | expect(slideRows([0, 1, 2], 1, 1, 1)).toEqual([0, 1, 2]);
12 | expect(slideRows([0, 1, 2], 1, 2, 1)).toEqual([0, 2, 1]);
13 | expect(slideRows([0, 1, 2], 1, 0, 1)).toEqual([1, 0, 2]);
14 | expect(slideRows([0, 1, 2], 0, 2, 1)).toEqual([1, 2, 0]);
15 | });
16 |
17 | it('should handle multi slides', () => {
18 | expect(slideRows([0, 1, 2], 1, 0, 2)).toEqual([1, 2, 0]);
19 | expect(slideRows([0, 1, 2, 3], 0, 2, 2)).toEqual([2, 3, 0, 1]);
20 | expect(slideRows([0, 1, 2, 3], 3, 0, 2)).toEqual([3, 0, 1, 2]);
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/utils/memoized-tree-data-utils.js:
--------------------------------------------------------------------------------
1 | import {
2 | insertNode,
3 | getDescendantCount,
4 | getFlatDataFromTree,
5 | } from './tree-data-utils';
6 |
7 | const memoize = f => {
8 | let savedArgsArray = [];
9 | let savedKeysArray = [];
10 | let savedResult = null;
11 |
12 | return args => {
13 | const keysArray = Object.keys(args).sort();
14 | const argsArray = keysArray.map(key => args[key]);
15 |
16 | // If the arguments for the last insert operation are different than this time,
17 | // recalculate the result
18 | if (
19 | argsArray.length !== savedArgsArray.length ||
20 | argsArray.some((arg, index) => arg !== savedArgsArray[index]) ||
21 | keysArray.some((key, index) => key !== savedKeysArray[index])
22 | ) {
23 | savedArgsArray = argsArray;
24 | savedKeysArray = keysArray;
25 | savedResult = f(args);
26 | }
27 |
28 | return savedResult;
29 | };
30 | };
31 |
32 | export const memoizedInsertNode = memoize(insertNode);
33 | export const memoizedGetFlatDataFromTree = memoize(getFlatDataFromTree);
34 | export const memoizedGetDescendantCount = memoize(getDescendantCount);
35 |
--------------------------------------------------------------------------------
/src/utils/memoized-tree-data-utils.test.js:
--------------------------------------------------------------------------------
1 | import { insertNode } from './tree-data-utils';
2 |
3 | import { memoizedInsertNode } from './memoized-tree-data-utils';
4 |
5 | describe('insertNode', () => {
6 | it('should handle empty data', () => {
7 | const params = {
8 | treeData: [],
9 | depth: 0,
10 | minimumTreeIndex: 0,
11 | newNode: {},
12 | getNodeKey: ({ treeIndex }) => treeIndex,
13 | };
14 |
15 | let firstCall = insertNode(params);
16 | let secondCall = insertNode(params);
17 | expect(firstCall === secondCall).toEqual(false);
18 |
19 | firstCall = memoizedInsertNode(params);
20 | secondCall = memoizedInsertNode(params);
21 | expect(firstCall === secondCall).toEqual(true);
22 |
23 | expect(
24 | memoizedInsertNode(params) ===
25 | memoizedInsertNode({ ...params, treeData: [{}] })
26 | ).toEqual(false);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/stories/add-remove.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree, { addNodeUnderParent, removeNodeAtPath } from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | const firstNames = [
7 | 'Abraham',
8 | 'Adam',
9 | 'Agnar',
10 | 'Albert',
11 | 'Albin',
12 | 'Albrecht',
13 | 'Alexander',
14 | 'Alfred',
15 | 'Alvar',
16 | 'Ander',
17 | 'Andrea',
18 | 'Arthur',
19 | 'Axel',
20 | 'Bengt',
21 | 'Bernhard',
22 | 'Carl',
23 | 'Daniel',
24 | 'Einar',
25 | 'Elmer',
26 | 'Eric',
27 | 'Erik',
28 | 'Gerhard',
29 | 'Gunnar',
30 | 'Gustaf',
31 | 'Harald',
32 | 'Herbert',
33 | 'Herman',
34 | 'Johan',
35 | 'John',
36 | 'Karl',
37 | 'Leif',
38 | 'Leonard',
39 | 'Martin',
40 | 'Matt',
41 | 'Mikael',
42 | 'Nikla',
43 | 'Norman',
44 | 'Oliver',
45 | 'Olof',
46 | 'Olvir',
47 | 'Otto',
48 | 'Patrik',
49 | 'Peter',
50 | 'Petter',
51 | 'Robert',
52 | 'Rupert',
53 | 'Sigurd',
54 | 'Simon',
55 | ];
56 |
57 | export default class App extends Component {
58 | constructor(props) {
59 | super(props);
60 |
61 | this.state = {
62 | treeData: [{ title: 'Peter Olofsson' }, { title: 'Karl Johansson' }],
63 | addAsFirstChild: false,
64 | };
65 | }
66 |
67 | render() {
68 | const getNodeKey = ({ treeIndex }) => treeIndex;
69 | const getRandomName = () =>
70 | firstNames[Math.floor(Math.random() * firstNames.length)];
71 | return (
72 |
73 |
74 | this.setState({ treeData })}
77 | generateNodeProps={({ node, path }) => ({
78 | buttons: [
79 |
81 | this.setState(state => ({
82 | treeData: addNodeUnderParent({
83 | treeData: state.treeData,
84 | parentKey: path[path.length - 1],
85 | expandParent: true,
86 | getNodeKey,
87 | newNode: {
88 | title: `${getRandomName()} ${
89 | node.title.split(' ')[0]
90 | }sson`,
91 | },
92 | addAsFirstChild: state.addAsFirstChild,
93 | }).treeData,
94 | }))
95 | }
96 | >
97 | Add Child
98 | ,
99 |
101 | this.setState(state => ({
102 | treeData: removeNodeAtPath({
103 | treeData: state.treeData,
104 | path,
105 | getNodeKey,
106 | }),
107 | }))
108 | }
109 | >
110 | Remove
111 | ,
112 | ],
113 | })}
114 | />
115 |
116 |
117 |
119 | this.setState(state => ({
120 | treeData: state.treeData.concat({
121 | title: `${getRandomName()} ${getRandomName()}sson`,
122 | }),
123 | }))
124 | }
125 | >
126 | Add more
127 |
128 |
129 |
130 | Add new nodes at start
131 |
136 | this.setState(state => ({
137 | addAsFirstChild: !state.addAsFirstChild,
138 | }))
139 | }
140 | />
141 |
142 |
143 | );
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/stories/barebones-no-context.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { DndProvider } from 'react-dnd';
3 | import { HTML5Backend } from 'react-dnd-html5-backend';
4 | import { SortableTreeWithoutDndContext as SortableTree } from '../src';
5 | // In your own app, you would need to use import styles once in the app
6 | // import 'react-sortable-tree/styles.css';
7 |
8 | export default class App extends Component {
9 | constructor(props) {
10 | super(props);
11 |
12 | this.state = {
13 | treeData: [
14 | { title: 'Chicken', expanded: true, children: [{ title: 'Egg' }] },
15 | ],
16 | };
17 | }
18 |
19 | render() {
20 | return (
21 |
22 |
23 | this.setState({ treeData })}
26 | />
27 |
28 |
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/stories/barebones.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | export default class App extends Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = {
11 | treeData: [
12 | { title: 'Chicken', expanded: true, children: [{ title: 'Egg' }] },
13 | ],
14 | };
15 | }
16 |
17 | render() {
18 | return (
19 |
20 | this.setState({ treeData })}
23 | />
24 |
25 | );
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/stories/callbacks.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | export default class App extends Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = {
11 | treeData: [
12 | { title: 'A', expanded: true, children: [{ title: 'B' }] },
13 | { title: 'C' },
14 | ],
15 | lastMovePrevPath: null,
16 | lastMoveNextPath: null,
17 | lastMoveNode: null,
18 | };
19 | }
20 |
21 | render() {
22 | const { lastMovePrevPath, lastMoveNextPath, lastMoveNode } = this.state;
23 |
24 | const recordCall = (name, args) => {
25 | // eslint-disable-next-line no-console
26 | console.log(`${name} called with arguments:`, args);
27 | };
28 |
29 | return (
30 |
31 | Open your console to see callback parameter info
32 |
33 | this.setState({ treeData })}
36 | // Need to set getNodeKey to get meaningful ids in paths
37 | getNodeKey={({ node }) => `node${node.title}`}
38 | onVisibilityToggle={args => recordCall('onVisibilityToggle', args)}
39 | onMoveNode={args => {
40 | recordCall('onMoveNode', args);
41 | const { prevPath, nextPath, node } = args;
42 | this.setState({
43 | lastMovePrevPath: prevPath,
44 | lastMoveNextPath: nextPath,
45 | lastMoveNode: node,
46 | });
47 | }}
48 | onDragStateChanged={args => recordCall('onDragStateChanged', args)}
49 | />
50 |
51 | {lastMoveNode && (
52 |
53 | Node "{lastMoveNode.title}" moved from path [
54 | {lastMovePrevPath.join(',')}] to path [{lastMoveNextPath.join(',')}
55 | ].
56 |
57 | )}
58 |
59 | );
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/stories/can-drop.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | export default class App extends Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = {
11 | treeData: [
12 | {
13 | id: 'trap',
14 | title: 'Wicked witch',
15 | subtitle: 'Traps people',
16 | expanded: true,
17 | children: [{ id: 'trapped', title: 'Trapped' }],
18 | },
19 | {
20 | id: 'no-grandkids',
21 | title: 'Jeannie',
22 | subtitle: "Doesn't allow grandchildren",
23 | expanded: true,
24 | children: [{ id: 'jimmy', title: 'Jimmy' }],
25 | },
26 | {
27 | id: 'twin-1',
28 | title: 'Twin #1',
29 | isTwin: true,
30 | subtitle: "Doesn't play with other twin",
31 | },
32 | {
33 | id: 'twin-2',
34 | title: 'Twin #2',
35 | isTwin: true,
36 | subtitle: "Doesn't play with other twin",
37 | },
38 | ],
39 | };
40 | }
41 |
42 | render() {
43 | const canDrop = ({ node, nextParent, prevPath, nextPath }) => {
44 | if (prevPath.indexOf('trap') >= 0 && nextPath.indexOf('trap') < 0) {
45 | return false;
46 | }
47 |
48 | if (node.isTwin && nextParent && nextParent.isTwin) {
49 | return false;
50 | }
51 |
52 | const noGrandkidsDepth = nextPath.indexOf('no-grandkids');
53 | if (noGrandkidsDepth >= 0 && nextPath.length - noGrandkidsDepth > 2) {
54 | return false;
55 | }
56 |
57 | return true;
58 | };
59 |
60 | return (
61 |
62 | node.id}
67 | onChange={treeData => this.setState({ treeData })}
68 | />
69 |
70 | );
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/stories/childless-nodes.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | export default class App extends Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = {
11 | treeData: [
12 | {
13 | title: 'Managers',
14 | expanded: true,
15 | children: [
16 | {
17 | title: 'Rob',
18 | children: [],
19 | isPerson: true,
20 | },
21 | {
22 | title: 'Joe',
23 | children: [],
24 | isPerson: true,
25 | },
26 | ],
27 | },
28 | {
29 | title: 'Clerks',
30 | expanded: true,
31 | children: [
32 | {
33 | title: 'Bertha',
34 | children: [],
35 | isPerson: true,
36 | },
37 | {
38 | title: 'Billy',
39 | children: [],
40 | isPerson: true,
41 | },
42 | ],
43 | },
44 | ],
45 | };
46 | }
47 |
48 | render() {
49 | return (
50 |
51 |
52 | !node.isPerson}
55 | onChange={treeData => this.setState({ treeData })}
56 | />
57 |
58 |
59 | );
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/stories/drag-out-to-remove.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-multi-comp */
2 | import PropTypes from 'prop-types';
3 | import React, { Component } from 'react';
4 | import { DndProvider, DropTarget } from 'react-dnd';
5 | import { HTML5Backend } from 'react-dnd-html5-backend';
6 | import { SortableTreeWithoutDndContext as SortableTree } from '../src';
7 | // In your own app, you would need to use import styles once in the app
8 | // import 'react-sortable-tree/styles.css';
9 |
10 | // -------------------------
11 | // Create an drop target component that can receive the nodes
12 | // https://react-dnd.github.io/react-dnd/docs-drop-target.html
13 | // -------------------------
14 | // This type must be assigned to the tree via the `dndType` prop as well
15 | const trashAreaType = 'yourNodeType';
16 | const trashAreaSpec = {
17 | // The endDrag handler on the tree source will use some of the properties of
18 | // the source, like node, treeIndex, and path to determine where it was before.
19 | // The treeId must be changed, or it interprets it as dropping within itself.
20 | drop: (props, monitor) => ({ ...monitor.getItem(), treeId: 'trash' }),
21 | };
22 | const trashAreaCollect = (connect, monitor) => ({
23 | connectDropTarget: connect.dropTarget(),
24 | isOver: monitor.isOver({ shallow: true }),
25 | });
26 |
27 | // The component will sit around the tree component and catch
28 | // nodes dragged out
29 | class trashAreaBaseComponent extends Component {
30 | render() {
31 | const { connectDropTarget, children, isOver } = this.props;
32 |
33 | return connectDropTarget(
34 |
41 | {children}
42 |
43 | );
44 | }
45 | }
46 | trashAreaBaseComponent.propTypes = {
47 | connectDropTarget: PropTypes.func.isRequired,
48 | children: PropTypes.node.isRequired,
49 | isOver: PropTypes.bool.isRequired,
50 | };
51 | const TrashAreaComponent = DropTarget(
52 | trashAreaType,
53 | trashAreaSpec,
54 | trashAreaCollect
55 | )(trashAreaBaseComponent);
56 |
57 | class App extends Component {
58 | constructor(props) {
59 | super(props);
60 |
61 | this.state = {
62 | treeData: [
63 | { title: '1' },
64 | { title: '2' },
65 | { title: '3' },
66 | { title: '4', expanded: true, children: [{ title: '5' }] },
67 | ],
68 | };
69 | }
70 |
71 | render() {
72 | return (
73 |
74 |
75 |
76 |
77 | this.setState({ treeData })}
80 | dndType={trashAreaType}
81 | />
82 |
83 |
84 |
85 |
86 | );
87 | }
88 | }
89 |
90 | export default App;
91 |
--------------------------------------------------------------------------------
/stories/external-node.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-multi-comp */
2 | import PropTypes from 'prop-types';
3 | import React, { Component } from 'react';
4 | import { DndProvider, DragSource } from 'react-dnd';
5 | import { HTML5Backend } from 'react-dnd-html5-backend';
6 | import { SortableTreeWithoutDndContext as SortableTree } from '../src';
7 | // In your own app, you would need to use import styles once in the app
8 | // import 'react-sortable-tree/styles.css';
9 |
10 | // -------------------------
11 | // Create an drag source component that can be dragged into the tree
12 | // https://react-dnd.github.io/react-dnd/docs-drag-source.html
13 | // -------------------------
14 | // This type must be assigned to the tree via the `dndType` prop as well
15 | const externalNodeType = 'yourNodeType';
16 | const externalNodeSpec = {
17 | // This needs to return an object with a property `node` in it.
18 | // Object rest spread is recommended to avoid side effects of
19 | // referencing the same object in different trees.
20 | beginDrag: componentProps => ({ node: { ...componentProps.node } }),
21 | };
22 | const externalNodeCollect = (connect /* , monitor */) => ({
23 | connectDragSource: connect.dragSource(),
24 | // Add props via react-dnd APIs to enable more visual
25 | // customization of your component
26 | // isDragging: monitor.isDragging(),
27 | // didDrop: monitor.didDrop(),
28 | });
29 | class externalNodeBaseComponent extends Component {
30 | render() {
31 | const { connectDragSource, node } = this.props;
32 |
33 | return connectDragSource(
34 |
42 | {node.title}
43 |
,
44 | { dropEffect: 'copy' }
45 | );
46 | }
47 | }
48 | externalNodeBaseComponent.propTypes = {
49 | node: PropTypes.shape({ title: PropTypes.string }).isRequired,
50 | connectDragSource: PropTypes.func.isRequired,
51 | };
52 | const YourExternalNodeComponent = DragSource(
53 | externalNodeType,
54 | externalNodeSpec,
55 | externalNodeCollect
56 | )(externalNodeBaseComponent);
57 |
58 | class App extends Component {
59 | constructor(props) {
60 | super(props);
61 |
62 | this.state = {
63 | treeData: [{ title: 'Mama Rabbit' }, { title: 'Papa Rabbit' }],
64 | };
65 | }
66 |
67 | render() {
68 | return (
69 |
70 |
71 |
72 | this.setState({ treeData })}
75 | dndType={externalNodeType}
76 | />
77 |
78 |
← drag
79 | this
80 |
81 |
82 | );
83 | }
84 | }
85 |
86 | export default App;
87 |
--------------------------------------------------------------------------------
/stories/generate-node-props.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree, { changeNodeAtPath } from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | export default class App extends Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = {
11 | treeData: [
12 | { id: 1, position: 'Goalkeeper' },
13 | { id: 2, position: 'Wing-back' },
14 | {
15 | id: 3,
16 | position: 'Striker',
17 | children: [{ id: 4, position: 'Full-back' }],
18 | },
19 | ],
20 | };
21 | }
22 |
23 | render() {
24 | const TEAM_COLORS = ['Red', 'Black', 'Green', 'Blue'];
25 | const getNodeKey = ({ node: { id } }) => id;
26 | return (
27 |
28 |
29 | this.setState({ treeData })}
32 | getNodeKey={getNodeKey}
33 | generateNodeProps={({ node, path }) => {
34 | const rootLevelIndex =
35 | this.state.treeData.reduce((acc, n, index) => {
36 | if (acc !== null) {
37 | return acc;
38 | }
39 | if (path[0] === n.id) {
40 | return index;
41 | }
42 | return null;
43 | }, null) || 0;
44 | const playerColor = TEAM_COLORS[rootLevelIndex];
45 |
46 | return {
47 | style: {
48 | boxShadow: `0 0 0 4px ${playerColor.toLowerCase()}`,
49 | textShadow:
50 | path.length === 1
51 | ? `1px 1px 1px ${playerColor.toLowerCase()}`
52 | : 'none',
53 | },
54 | title: `${playerColor} ${
55 | path.length === 1 ? 'Captain' : node.position
56 | }`,
57 | onClick: () => {
58 | this.setState(state => ({
59 | treeData: changeNodeAtPath({
60 | treeData: state.treeData,
61 | path,
62 | getNodeKey,
63 | newNode: { ...node, expanded: !node.expanded },
64 | }),
65 | }));
66 | },
67 | };
68 | }}
69 | />
70 |
71 |
72 | );
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/stories/generic.css:
--------------------------------------------------------------------------------
1 | .sourceLink,
2 | .sandboxButton {
3 | position: fixed;
4 | top: 0;
5 | right: 0;
6 | padding: 130px 50px 5px 50px;
7 | font: 10px helvetica, sans-serif;
8 | display: inline-block;
9 | background: rgb(12, 35, 194);
10 | color: #fff;
11 | text-decoration: none;
12 | transform: translate(50%, -50%) rotateZ(45deg);
13 | transition: background 100ms;
14 | }
15 | .sourceLink:hover:not(:active) {
16 | background: rgb(102, 135, 244);
17 | }
18 |
19 | .sandboxButton {
20 | top: 30px;
21 | right: 30px;
22 | background: rgb(12, 194, 68);
23 | padding: 130px 100px 5px 100px;
24 | border: none;
25 | cursor: pointer;
26 | outline: none;
27 | }
28 | .sandboxButton:hover:not(:active) {
29 | background: rgb(128, 242, 137);
30 | }
31 |
--------------------------------------------------------------------------------
/stories/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 |
3 | import { storiesOf } from '@storybook/react';
4 | import React from 'react';
5 | import AddRemoveExample from './add-remove';
6 | import BarebonesExample from './barebones';
7 | import BarebonesExampleNoContext from './barebones-no-context';
8 | import CallbacksExample from './callbacks';
9 | import CanDropExample from './can-drop';
10 | import ChildlessNodes from './childless-nodes';
11 | import DragOutToRemoveExample from './drag-out-to-remove';
12 | import ExternalNodeExample from './external-node';
13 | import GenerateNodePropsExample from './generate-node-props';
14 | import './generic.css';
15 | import ModifyNodesExample from './modify-nodes';
16 | import OnlyExpandSearchedNodesExample from './only-expand-searched-node';
17 | import RowDirectionExample from './rtl-support';
18 | import SearchExample from './search';
19 | import ThemesExample from './themes';
20 | import TouchSupportExample from './touch-support';
21 | import TreeDataIOExample from './tree-data-io';
22 | import TreeToTreeExample from './tree-to-tree';
23 |
24 | storiesOf('Basics', module)
25 | .add('Minimal implementation', () => )
26 | .add('treeData import/export', () => )
27 | .add('Add and remove nodes programmatically', () => )
28 | .add('Modify nodes', () => )
29 | .add('Prevent drop', () => )
30 | .add('Search', () => )
31 | .add('Themes', () => )
32 | .add('Callbacks', () => )
33 | .add('Row direction support', () => );
34 |
35 | storiesOf('Advanced', module)
36 | .add('Drag from external source', () => )
37 | .add('Touch support (Experimental)', () => )
38 | .add('Tree-to-tree dragging', () => , 'tree-to-tree.js')
39 | .add('Playing with generateNodeProps', () => )
40 | .add('Drag out to remove', () => )
41 | .add('onlyExpandSearchedNodes', () => )
42 | .add('Prevent some nodes from having children', () => )
43 | .add('Minimal implementation without Dnd Context', () => (
44 |
45 | ));
46 |
--------------------------------------------------------------------------------
/stories/modify-nodes.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree, { changeNodeAtPath } from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | export default class App extends Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = {
11 | treeData: [
12 | { name: 'IT Manager' },
13 | {
14 | name: 'Regional Manager',
15 | expanded: true,
16 | children: [{ name: 'Branch Manager' }],
17 | },
18 | ],
19 | };
20 | }
21 |
22 | render() {
23 | const getNodeKey = ({ treeIndex }) => treeIndex;
24 | return (
25 |
26 |
27 | this.setState({ treeData })}
30 | generateNodeProps={({ node, path }) => ({
31 | title: (
32 | {
36 | const name = event.target.value;
37 |
38 | this.setState(state => ({
39 | treeData: changeNodeAtPath({
40 | treeData: state.treeData,
41 | path,
42 | getNodeKey,
43 | newNode: { ...node, name },
44 | }),
45 | }));
46 | }}
47 | />
48 | ),
49 | })}
50 | />
51 |
52 |
53 | );
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/stories/only-expand-searched-node.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | export default class App extends Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | const title = 'Hay';
11 |
12 | // For generating a haystack (you probably won't need to do this)
13 | const getStack = (left, hasNeedle = false) => {
14 | if (left === 0) {
15 | return hasNeedle ? { title: 'Needle' } : { title };
16 | }
17 |
18 | return {
19 | title,
20 | children: [
21 | {
22 | title,
23 | children: [getStack(left - 1, hasNeedle && left % 2), { title }],
24 | },
25 | { title },
26 | {
27 | title,
28 | children: [
29 | { title },
30 | getStack(left - 1, hasNeedle && (left + 1) % 2),
31 | ],
32 | },
33 | ],
34 | };
35 | };
36 |
37 | this.state = {
38 | searchString: '',
39 | searchFocusIndex: 0,
40 | searchFoundCount: null,
41 | treeData: [
42 | {
43 | title: 'Haystack',
44 | children: [
45 | getStack(3, true),
46 | getStack(3),
47 | { title },
48 | getStack(2, true),
49 | ],
50 | },
51 | ],
52 | };
53 | }
54 |
55 | render() {
56 | const { searchString, searchFocusIndex, searchFoundCount } = this.state;
57 |
58 | // Case insensitive search of `node.title`
59 | const customSearchMethod = ({ node, searchQuery }) =>
60 | searchQuery &&
61 | node.title.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1;
62 |
63 | const selectPrevMatch = () =>
64 | this.setState({
65 | searchFocusIndex:
66 | searchFocusIndex !== null
67 | ? (searchFoundCount + searchFocusIndex - 1) % searchFoundCount
68 | : searchFoundCount - 1,
69 | });
70 |
71 | const selectNextMatch = () =>
72 | this.setState({
73 | searchFocusIndex:
74 | searchFocusIndex !== null
75 | ? (searchFocusIndex + 1) % searchFoundCount
76 | : 0,
77 | });
78 |
79 | return (
80 |
81 |
Find the needle!
82 |
122 |
123 |
124 | this.setState({ treeData })}
127 | //
128 | // Custom comparison for matching during search.
129 | // This is optional, and defaults to a case sensitive search of
130 | // the title and subtitle values.
131 | // see `defaultSearchMethod` in https://github.com/frontend-collective/react-sortable-tree/blob/master/src/utils/default-handlers.js
132 | searchMethod={customSearchMethod}
133 | //
134 | // The query string used in the search. This is required for searching.
135 | searchQuery={searchString}
136 | //
137 | // When matches are found, this property lets you highlight a specific
138 | // match and scroll to it. This is optional.
139 | searchFocusOffset={searchFocusIndex}
140 | //
141 | // This callback returns the matches from the search,
142 | // including their `node`s, `treeIndex`es, and `path`s
143 | // Here I just use it to note how many matches were found.
144 | // This is optional, but without it, the only thing searches
145 | // do natively is outline the matching nodes.
146 | searchFinishCallback={matches =>
147 | this.setState({
148 | searchFoundCount: matches.length,
149 | searchFocusIndex:
150 | matches.length > 0 ? searchFocusIndex % matches.length : 0,
151 | })
152 | }
153 | //
154 | // This prop only expands the nodes that are seached.
155 | onlyExpandSearchedNodes
156 | />
157 |
158 |
159 | );
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/stories/rtl-support.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | export default class App extends Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = {
11 | treeData: [
12 | {
13 | title: 'Chicken',
14 | expanded: true,
15 | children: [
16 | { title: 'Egg' },
17 | { title: 'Egg' },
18 | { title: 'Egg' },
19 | { title: 'Egg' },
20 | { title: 'Egg' },
21 | { title: 'Egg' },
22 | ],
23 | },
24 | ],
25 | };
26 | }
27 |
28 | render() {
29 | return (
30 |
31 | this.setState({ treeData })}
35 | />
36 |
37 | );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/stories/search.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | export default class App extends Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | const title = 'Hay';
11 |
12 | // For generating a haystack (you probably won't need to do this)
13 | const getStack = (left, hasNeedle = false) => {
14 | if (left === 0) {
15 | return hasNeedle ? { title: 'Needle' } : { title };
16 | }
17 |
18 | return {
19 | title,
20 | children: [
21 | {
22 | title,
23 | children: [getStack(left - 1, hasNeedle && left % 2), { title }],
24 | },
25 | { title },
26 | {
27 | title,
28 | children: [
29 | { title },
30 | getStack(left - 1, hasNeedle && (left + 1) % 2),
31 | ],
32 | },
33 | ],
34 | };
35 | };
36 |
37 | this.state = {
38 | searchString: '',
39 | searchFocusIndex: 0,
40 | searchFoundCount: null,
41 | treeData: [
42 | {
43 | title: 'Haystack',
44 | children: [
45 | getStack(3, true),
46 | getStack(3),
47 | { title },
48 | getStack(2, true),
49 | ],
50 | },
51 | ],
52 | };
53 | }
54 |
55 | render() {
56 | const { searchString, searchFocusIndex, searchFoundCount } = this.state;
57 |
58 | // Case insensitive search of `node.title`
59 | const customSearchMethod = ({ node, searchQuery }) =>
60 | searchQuery &&
61 | node.title.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1;
62 |
63 | const selectPrevMatch = () =>
64 | this.setState({
65 | searchFocusIndex:
66 | searchFocusIndex !== null
67 | ? (searchFoundCount + searchFocusIndex - 1) % searchFoundCount
68 | : searchFoundCount - 1,
69 | });
70 |
71 | const selectNextMatch = () =>
72 | this.setState({
73 | searchFocusIndex:
74 | searchFocusIndex !== null
75 | ? (searchFocusIndex + 1) % searchFoundCount
76 | : 0,
77 | });
78 |
79 | return (
80 |
81 |
Find the needle!
82 |
122 |
123 |
124 | this.setState({ treeData })}
127 | //
128 | // Custom comparison for matching during search.
129 | // This is optional, and defaults to a case sensitive search of
130 | // the title and subtitle values.
131 | // see `defaultSearchMethod` in https://github.com/frontend-collective/react-sortable-tree/blob/master/src/utils/default-handlers.js
132 | searchMethod={customSearchMethod}
133 | //
134 | // The query string used in the search. This is required for searching.
135 | searchQuery={searchString}
136 | //
137 | // When matches are found, this property lets you highlight a specific
138 | // match and scroll to it. This is optional.
139 | searchFocusOffset={searchFocusIndex}
140 | //
141 | // This callback returns the matches from the search,
142 | // including their `node`s, `treeIndex`es, and `path`s
143 | // Here I just use it to note how many matches were found.
144 | // This is optional, but without it, the only thing searches
145 | // do natively is outline the matching nodes.
146 | searchFinishCallback={matches =>
147 | this.setState({
148 | searchFoundCount: matches.length,
149 | searchFocusIndex:
150 | matches.length > 0 ? searchFocusIndex % matches.length : 0,
151 | })
152 | }
153 | />
154 |
155 |
156 | );
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/stories/storyshots.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import initStoryshots, {
3 | snapshotWithOptions,
4 | } from '@storybook/addon-storyshots';
5 |
6 | initStoryshots({
7 | test: snapshotWithOptions({
8 | createNodeMock: () => ({}),
9 | }),
10 | });
11 |
--------------------------------------------------------------------------------
/stories/themes.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import React, { Component } from 'react';
3 | import FileExplorerTheme from 'react-sortable-tree-theme-file-explorer';
4 | import SortableTree from '../src';
5 | // In your own app, you would need to use import styles once in the app
6 | // import 'react-sortable-tree/styles.css';
7 |
8 | export default class App extends Component {
9 | constructor(props) {
10 | super(props);
11 |
12 | this.state = {
13 | treeData: [
14 | {
15 | title: 'The file explorer theme',
16 | expanded: true,
17 | children: [
18 | {
19 | title: 'Imported from react-sortable-tree-theme-file-explorer',
20 | expanded: true,
21 | children: [
22 | {
23 | title: (
24 |
30 | ),
31 | },
32 | ],
33 | },
34 | ],
35 | },
36 | { title: 'More compact than the default' },
37 | {
38 | title: (
39 |
40 | Simply set it to the theme
prop and you’re
41 | done!
42 |
43 | ),
44 | },
45 | ],
46 | };
47 | }
48 |
49 | render() {
50 | return (
51 |
52 | this.setState({ treeData })}
56 | />
57 |
58 | );
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/stories/touch-support.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import React, { Component } from 'react';
3 | import { DndProvider } from 'react-dnd';
4 | import { HTML5Backend } from 'react-dnd-html5-backend';
5 | import TouchBackend from 'react-dnd-touch-backend';
6 | import { SortableTreeWithoutDndContext as SortableTree } from '../src';
7 | // In your own app, you would need to use import styles once in the app
8 | // import 'react-sortable-tree/styles.css';
9 |
10 | // https://stackoverflow.com/a/4819886/1601953
11 | const isTouchDevice = !!('ontouchstart' in window || navigator.maxTouchPoints);
12 | const dndBackend = isTouchDevice ? TouchBackend : HTML5Backend;
13 |
14 | class App extends Component {
15 | constructor(props) {
16 | super(props);
17 |
18 | this.state = {
19 | treeData: [
20 | { title: 'Chicken', expanded: true, children: [{ title: 'Egg' }] },
21 | ],
22 | };
23 | }
24 |
25 | render() {
26 | return (
27 |
28 |
29 |
30 | This is {!isTouchDevice && 'not '}a touch-supporting browser
31 |
32 |
33 |
34 | this.setState({ treeData })}
37 | />
38 |
39 |
40 |
41 | );
42 | }
43 | }
44 |
45 | export default App;
46 |
--------------------------------------------------------------------------------
/stories/tree-data-io.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree, { getFlatDataFromTree, getTreeFromFlatData } from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | const initialData = [
7 | { id: '1', name: 'N1', parent: null },
8 | { id: '2', name: 'N2', parent: null },
9 | { id: '3', name: 'N3', parent: 2 },
10 | { id: '4', name: 'N4', parent: 3 },
11 | ];
12 |
13 | export default class App extends Component {
14 | constructor(props) {
15 | super(props);
16 |
17 | this.state = {
18 | treeData: getTreeFromFlatData({
19 | flatData: initialData.map(node => ({ ...node, title: node.name })),
20 | getKey: node => node.id, // resolve a node's key
21 | getParentKey: node => node.parent, // resolve a node's parent's key
22 | rootKey: null, // The value of the parent key when there is no parent (i.e., at root level)
23 | }),
24 | };
25 | }
26 |
27 | render() {
28 | const flatData = getFlatDataFromTree({
29 | treeData: this.state.treeData,
30 | getNodeKey: ({ node }) => node.id, // This ensures your "id" properties are exported in the path
31 | ignoreCollapsed: false, // Makes sure you traverse every node in the tree, not just the visible ones
32 | }).map(({ node, path }) => ({
33 | id: node.id,
34 | name: node.name,
35 |
36 | // The last entry in the path is this node's key
37 | // The second to last entry (accessed here) is the parent node's key
38 | parent: path.length > 1 ? path[path.length - 2] : null,
39 | }));
40 |
41 | return (
42 |
43 | ↓treeData for this tree was generated from flat data similar to DB rows↓
44 |
45 | this.setState({ treeData })}
48 | />
49 |
50 |
51 | ↓This flat data is generated from the modified tree data↓
52 |
53 | {flatData.map(({ id, name, parent }) => (
54 |
55 | id: {id}, name: {name}, parent: {parent || 'null'}
56 |
57 | ))}
58 |
59 |
60 | );
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/stories/tree-to-tree.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import SortableTree from '../src';
3 | // In your own app, you would need to use import styles once in the app
4 | // import 'react-sortable-tree/styles.css';
5 |
6 | class App extends Component {
7 | constructor(props) {
8 | super(props);
9 |
10 | this.state = {
11 | treeData1: [
12 | { title: 'node1', children: [{ title: 'Child node' }] },
13 | { title: 'node2' },
14 | ],
15 | treeData2: [{ title: 'node3' }, { title: 'node4' }],
16 | shouldCopyOnOutsideDrop: false,
17 | };
18 | }
19 |
20 | render() {
21 | // Both trees need to share this same node type in their
22 | // `dndType` prop
23 | const externalNodeType = 'yourNodeType';
24 | const { shouldCopyOnOutsideDrop } = this.state;
25 | return (
26 |
27 |
35 | this.setState({ treeData1 })}
38 | dndType={externalNodeType}
39 | shouldCopyOnOutsideDrop={shouldCopyOnOutsideDrop}
40 | />
41 |
42 |
43 |
51 | this.setState({ treeData2 })}
54 | dndType={externalNodeType}
55 | shouldCopyOnOutsideDrop={shouldCopyOnOutsideDrop}
56 | />
57 |
58 |
59 |
60 |
61 |
62 |
63 | Enable node copy via shouldCopyOnOutsideDrop :
64 |
69 | this.setState({
70 | shouldCopyOnOutsideDrop: event.target.checked,
71 | })
72 | }
73 | />
74 |
75 |
76 |
77 | );
78 | }
79 | }
80 |
81 | export default App;
82 |
--------------------------------------------------------------------------------
/test-config/shim.js:
--------------------------------------------------------------------------------
1 | global.requestAnimationFrame = callback => {
2 | setTimeout(callback, 0);
3 | };
4 |
--------------------------------------------------------------------------------
/test-config/test-setup.js:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 |
4 | configure({ adapter: new Adapter() });
5 |
--------------------------------------------------------------------------------