├── .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 | ![NPM version](https://img.shields.io/npm/v/react-sortable-tree.svg?style=flat) 12 | ![NPM license](https://img.shields.io/npm/l/react-sortable-tree.svg?style=flat) 13 | [![NPM total downloads](https://img.shields.io/npm/dt/react-sortable-tree.svg?style=flat)](https://npmcharts.com/compare/react-sortable-tree?minimal=true) 14 | [![NPM monthly downloads](https://img.shields.io/npm/dm/react-sortable-tree.svg?style=flat)](https://npmcharts.com/compare/react-sortable-tree?minimal=true) 15 | [![Build Status](https://travis-ci.org/frontend-collective/react-sortable-tree.svg?branch=master)](https://travis-ci.org/frontend-collective/react-sortable-tree) 16 | [![Coverage Status](https://coveralls.io/repos/github/frontend-collective/react-sortable-tree/badge.svg?branch=master)](https://coveralls.io/github/frontend-collective/react-sortable-tree?branch=master) 17 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](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 | | ![File Explorer Theme](https://user-images.githubusercontent.com/4413963/32144502-1df1ae08-bcfd-11e7-8f63-8b836dace1a4.png) | Full Node Drag Theme | MINIMAL THEME | 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 |
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 | , 99 | , 112 | ], 113 | })} 114 | /> 115 |
116 | 117 | 128 |
129 | 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 |
{ 85 | event.preventDefault(); 86 | }} 87 | > 88 | 95 | this.setState({ searchString: event.target.value }) 96 | } 97 | /> 98 | 99 | 106 | 107 | 114 | 115 | 116 |   117 | {searchFoundCount > 0 ? searchFocusIndex + 1 : 0} 118 |  /  119 | {searchFoundCount || 0} 120 | 121 |
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 |
{ 85 | event.preventDefault(); 86 | }} 87 | > 88 | 95 | this.setState({ searchString: event.target.value }) 96 | } 97 | /> 98 | 99 | 106 | 107 | 114 | 115 | 116 |   117 | {searchFoundCount > 0 ? searchFocusIndex + 1 : 0} 118 |  /  119 | {searchFoundCount || 0} 120 | 121 |
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 |
25 | Find it on{' '} 26 | 27 | npm 28 | 29 |
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 | 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 | --------------------------------------------------------------------------------