├── .gitignore ├── app ├── styles │ └── application.styl ├── redux │ ├── reducer.js │ └── store.js ├── routes.js ├── containers │ ├── App.js │ └── Index.js ├── index.js └── components │ ├── Tree.js │ └── Item.js ├── README.md ├── index.html ├── .babelrc ├── devServer.js ├── package.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | public/index.html 4 | public/js 5 | -------------------------------------------------------------------------------- /app/styles/application.styl: -------------------------------------------------------------------------------- 1 | body 2 | font-family: 'Helvetica', sans-serif 3 | line-height: 1.4 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #Nested list D&D example 2 | 3 | To run it in your computer: 4 | 5 | ```bash 6 | npm i 7 | npm start 8 | ``` 9 | 10 | [Demo](http://tamagokun.github.io/example-react-dnd-nested/) 11 | -------------------------------------------------------------------------------- /app/redux/reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import { routeReducer as routing } from 'redux-simple-router' 3 | import { reducer as form } from 'redux-form' 4 | 5 | export default combineReducers({ 6 | routing, 7 | form 8 | }) 9 | -------------------------------------------------------------------------------- /app/redux/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux' 2 | import thunk from 'redux-thunk' 3 | import reducer from './reducer' 4 | 5 | export default () => { 6 | return compose( 7 | applyMiddleware( 8 | thunk 9 | ), 10 | )(createStore)(reducer) 11 | } 12 | -------------------------------------------------------------------------------- /app/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, IndexRoute } from 'react-router' 3 | 4 | import App from './containers/App' 5 | import Index from './containers/Index' 6 | 7 | export default (store) => { 8 | return 9 | 10 | 11 | } 12 | -------------------------------------------------------------------------------- /app/containers/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { DragDropContext } from 'react-dnd' 3 | import HTML5Backend from 'react-dnd-html5-backend' 4 | 5 | @DragDropContext(HTML5Backend) 6 | export default class App extends Component { 7 | render() { 8 | return
9 | {this.props.children} 10 |
11 | } 12 | } 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Example 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-0"], 3 | "plugins": ["transform-decorators-legacy"], 4 | "env": { 5 | "development": { 6 | "plugins": [ 7 | ["react-transform", { 8 | "transforms": [{ 9 | "transform": "react-transform-hmr", 10 | "imports": ["react"], 11 | "locals": ["module"] 12 | }, { 13 | "transform": "react-transform-catch-errors", 14 | "imports": ["react", "redbox-react"] 15 | }] 16 | }] 17 | ] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import './styles/application.styl' 2 | 3 | import React, { Component, PropTypes } from 'react' 4 | import { render } from 'react-dom' 5 | import { Provider } from 'react-redux' 6 | import { Router } from 'react-router' 7 | import { createHistory } from 'history' 8 | import { syncReduxAndRouter } from 'redux-simple-router' 9 | 10 | import createStore from './redux/store' 11 | import createRoutes from './routes' 12 | 13 | const store = createStore() 14 | const history = createHistory() 15 | const routes = createRoutes(store) 16 | syncReduxAndRouter(history, store) 17 | 18 | render( 19 | 20 | 21 | {routes} 22 | 23 | , 24 | document.getElementById('app') 25 | ) 26 | -------------------------------------------------------------------------------- /devServer.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var express = require('express') 3 | var webpack = require('webpack') 4 | var config = require('./webpack.config') 5 | 6 | var app = express() 7 | var compiler = webpack(config) 8 | var devMiddleware = require('webpack-dev-middleware')(compiler, { 9 | noInfo: true, 10 | publicPath: config.output.publicPath 11 | }) 12 | 13 | app.use(express.static('public')) 14 | app.use(devMiddleware) 15 | app.use(require('webpack-hot-middleware')(compiler)) 16 | 17 | app.get('*', function(req, res) { 18 | if (req.accepts('html')) { 19 | var indexPath = path.join(config.output.path, 'index.html') 20 | var index = devMiddleware.fileSystem.readFileSync(indexPath) 21 | res.set('Content-Type', 'text/html') 22 | res.send(index) 23 | } 24 | }) 25 | 26 | app.listen(3000, 'localhost', function(err) { 27 | if (err) { 28 | console.log(err) 29 | return 30 | } 31 | 32 | console.log('Listening at http://localhost:3000') 33 | }) 34 | -------------------------------------------------------------------------------- /app/components/Tree.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import { DropTarget } from 'react-dnd' 3 | import Item from './Item' 4 | 5 | const target = { 6 | drop() {}, 7 | 8 | hover(props, monitor) { 9 | const {id: draggedId, parent, items} = monitor.getItem() 10 | 11 | if (!monitor.isOver({shallow: true})) return 12 | 13 | const descendantNode = props.find(props.parent, items) 14 | if (descendantNode) return 15 | if (parent == props.parent || draggedId == props.parent) return 16 | 17 | props.move(draggedId, props.id, props.parent) 18 | } 19 | } 20 | 21 | @DropTarget('ITEM', target, (connect, monitor) => ({ 22 | connectDropTarget: connect.dropTarget() 23 | })) 24 | export default class Tree extends Component { 25 | static propTypes = { 26 | items : PropTypes.array.isRequired, 27 | parent : PropTypes.any, 28 | move : PropTypes.func.isRequired, 29 | find : PropTypes.func.isRequired 30 | }; 31 | 32 | render() { 33 | const {connectDropTarget, items, parent, move, find} = this.props 34 | 35 | return connectDropTarget( 36 |
43 | {items.map((item, i) => { 44 | return 52 | })} 53 |
54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "mike@ripeworks.com", 3 | "name": "example-react-dnd-nested", 4 | "description": "", 5 | "version": "0.0.1", 6 | "repository": { 7 | "type": "git", 8 | "url": "" 9 | }, 10 | "scripts": { 11 | "start": "node devServer", 12 | "build": "webpack -p" 13 | }, 14 | "devDependencies": { 15 | "babel-core": "^6.3.17", 16 | "babel-loader": "^6.2.0", 17 | "babel-plugin-react-transform": "^2.0.0-beta1", 18 | "babel-plugin-transform-decorators-legacy": "^1.2.0", 19 | "babel-preset-es2015": "^6.3.13", 20 | "babel-preset-react": "^6.3.13", 21 | "babel-preset-stage-0": "^6.3.13", 22 | "css-loader": "^0.15.6", 23 | "express": "^4.13.3", 24 | "file-loader": "^0.8.4", 25 | "html-webpack-plugin": "^1.7.0", 26 | "nib": "^1.1.0", 27 | "react-transform-catch-errors": "^1.0.0", 28 | "react-transform-hmr": "^1.0.1", 29 | "redbox-react": "^1.2.0", 30 | "redux-devtools": "^2.1.0", 31 | "script-loader": "^0.6.1", 32 | "style-loader": "^0.12.3", 33 | "stylus-loader": "^1.2.1", 34 | "url-loader": "^0.5.6", 35 | "webpack": "^1.12.9", 36 | "webpack-dev-middleware": "^1.4.0", 37 | "webpack-hot-middleware": "^2.6.0" 38 | }, 39 | "dependencies": { 40 | "classnames": "^2.1.3", 41 | "history": "1.13.1", 42 | "react": "^0.14.3", 43 | "react-dnd": "^2.0.2", 44 | "react-dnd-html5-backend": "^2.0.2", 45 | "react-dom": "^0.14.3", 46 | "react-redux": "^4.0.0", 47 | "react-router": "^1.0.2", 48 | "redux": "^3.0.5", 49 | "redux-action-types": "^1.0.1", 50 | "redux-form": "^4.0.3", 51 | "redux-simple-router": "^1.0.0", 52 | "redux-thunk": "^1.0.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/components/Item.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import { DragSource, DropTarget } from 'react-dnd' 3 | import Tree from './Tree' 4 | 5 | const source = { 6 | beginDrag(props) { 7 | return { 8 | id: props.id, 9 | parent: props.parent, 10 | items: props.item.children 11 | } 12 | }, 13 | 14 | isDragging(props, monitor) { 15 | return props.id == monitor.getItem().id 16 | } 17 | } 18 | 19 | const target = { 20 | canDrop() { 21 | return false 22 | }, 23 | 24 | hover(props, monitor) { 25 | const {id: draggedId} = monitor.getItem() 26 | const {id: overId} = props 27 | 28 | if (draggedId == overId || draggedId == props.parent) return 29 | if (!monitor.isOver({shallow: true})) return 30 | 31 | props.move(draggedId, overId, props.parent) 32 | } 33 | } 34 | 35 | @DropTarget('ITEM', target, connect => ({ 36 | connectDropTarget: connect.dropTarget() 37 | })) 38 | @DragSource('ITEM', source, (connect, monitor) => ({ 39 | connectDragSource: connect.dragSource(), 40 | connectDragPreview: connect.dragPreview(), 41 | isDragging: monitor.isDragging() 42 | })) 43 | export default class Item extends Component { 44 | static propTypes = { 45 | id : PropTypes.any.isRequired, 46 | parent : PropTypes.any, 47 | item : PropTypes.object, 48 | move : PropTypes.func, 49 | find : PropTypes.func 50 | }; 51 | 52 | render() { 53 | const { 54 | connectDropTarget, connectDragPreview, connectDragSource, 55 | item: {id, title, children}, parent, move, find 56 | } = this.props 57 | 58 | return connectDropTarget(connectDragPreview( 59 |
60 | {connectDragSource( 61 |
{title}
68 | )} 69 | 75 |
76 | )) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var nib = require('nib') 2 | var webpack = require('webpack') 3 | var HtmlWebpackPlugin = require('html-webpack-plugin') 4 | 5 | var env = process.env.NODE_ENV || 'development' 6 | 7 | var config = { 8 | path: __dirname + '/public', 9 | entry: [ 10 | './app/index.js' 11 | ], 12 | plugins: [ 13 | new webpack.NoErrorsPlugin(), 14 | new webpack.optimize.OccurenceOrderPlugin(), 15 | new webpack.DefinePlugin({ 16 | "process.env": { 17 | NODE_ENV: JSON.stringify(env) 18 | } 19 | }), 20 | new webpack.optimize.UglifyJsPlugin({ 21 | compressor: { 22 | warnings: false 23 | } 24 | }), 25 | new HtmlWebpackPlugin({ 26 | template: './index.html', 27 | hash: true, 28 | inject: 'body' 29 | }) 30 | ], 31 | devtool: 'source-map' 32 | } 33 | 34 | if (env == 'development') { 35 | config.entry.unshift('webpack-hot-middleware/client') 36 | config.plugins = [ 37 | new webpack.HotModuleReplacementPlugin(), 38 | new webpack.NoErrorsPlugin(), 39 | new webpack.DefinePlugin({"process.env": { NODE_ENV: JSON.stringify(env) }}), 40 | new HtmlWebpackPlugin({ 41 | template: './index.html', 42 | hash: true, 43 | inject: 'body' 44 | }) 45 | ] 46 | config.devtool = 'cheap-module-eval-source-map' 47 | } 48 | 49 | module.exports = { 50 | entry: config.entry, 51 | output: { 52 | path: config.path, 53 | filename: 'js/app.js', 54 | cssFilename: 'css/application.css', 55 | publicPath: '/', 56 | }, 57 | module: { 58 | loaders: [ 59 | { test: /\.js$/, loader: 'babel', exclude: /node_modules/}, 60 | { test: /\.styl$/, loader: 'style-loader!css-loader!stylus-loader' }, 61 | { test: /\.css$/, loader: 'style-loader!css-loader'}, 62 | { test: /\.(otf|eot|svg|ttf|woff)/, loader: 'url-loader' }, 63 | { test: /\.(jpg|png|gif|json)/, loader: 'url-loader' }, 64 | ] 65 | }, 66 | stylus: { 67 | 'include css': true, 68 | use: [nib()], 69 | }, 70 | resolve: { 71 | extensions: ['', '.js', '.json'], 72 | }, 73 | plugins: config.plugins, 74 | devtool: config.devtool 75 | } 76 | -------------------------------------------------------------------------------- /app/containers/Index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import Tree from '../components/Tree' 3 | 4 | export default class Index extends Component { 5 | state = { 6 | tree: [ 7 | { 8 | id: 1, title: 'Tatooine', 9 | children: [ 10 | {id: 2, title: 'Endor', children: []}, 11 | {id: 3, title: 'Hoth', children: []}, 12 | {id: 4, title: 'Dagobah', children: []}, 13 | ] 14 | }, 15 | { 16 | id: 5, title: 'Death Star', 17 | children: [] 18 | }, 19 | { 20 | id: 6, title: 'Alderaan', 21 | children: [ 22 | { 23 | id: 7, title: 'Bespin', 24 | children: [ 25 | {id: 8, title: 'Jakku', children: []} 26 | ] 27 | } 28 | ] 29 | } 30 | ] 31 | }; 32 | 33 | moveItem(id, afterId, nodeId) { 34 | if (id == afterId) return 35 | 36 | let {tree} = this.state 37 | 38 | const removeNode = (id, items) => { 39 | for (const node of items) { 40 | if (node.id == id) { 41 | items.splice(items.indexOf(node), 1) 42 | return 43 | } 44 | 45 | if (node.children && node.children.length) { 46 | removeNode(id, node.children) 47 | } 48 | } 49 | } 50 | 51 | const item = {...this.findItem(id, tree)} 52 | if (!item.id) { 53 | return 54 | } 55 | 56 | const dest = nodeId ? this.findItem(nodeId, tree).children : tree 57 | 58 | if (!afterId) { 59 | removeNode(id, tree) 60 | dest.push(item) 61 | } else { 62 | const index = dest.indexOf(dest.filter(v => v.id == afterId).shift()) 63 | removeNode(id, tree) 64 | dest.splice(index, 0, item) 65 | } 66 | 67 | this.setState({tree}) 68 | } 69 | 70 | findItem(id, items) { 71 | for (const node of items) { 72 | if (node.id == id) return node 73 | if (node.children && node.children.length) { 74 | const result = this.findItem(id, node.children) 75 | if (result) { 76 | return result 77 | } 78 | } 79 | } 80 | 81 | return false 82 | } 83 | 84 | render() { 85 | const {tree} = this.state 86 | 87 | return
88 | 94 |
95 | } 96 | } 97 | --------------------------------------------------------------------------------