Our Kanban application is almost usable now. It doesn't look that bad and there's some basic functionality in place. In this chapter I'll show you how to take it to the next level. We will integrate some drag and drop functionality as we set up React DnD. After this chapter you should be able to sort notes within a lane and drag them from a lane to another.
Before going further hit
npm i react-dnd --save
to add React DnD to the project. Next, we'll need to patch our application to use it. React DnD supports the idea of back-ends. This means it is possible to adapt it to work on different platforms. Even a testing back-end is feasible. As of writing it supports only HTML5 Drag and Drop API based back-end. As a result, the application won't work on touch yet.
To get started, we'll need to hook up React DnD's HTML5Backend with our App
. After this has been done we can start worrying about actual functionality.
app/components/App.jsx
...
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd/modules/backends/HTML5';
...
@DragDropContext(HTML5Backend)
export default class App extends React.Component {
...
}
After this change the application should look exactly the same as before. We are now ready to add some sweet functionality to it.
Next, we will need to tell React DnD what can be dragged and where. Since we want to move notes, we'll want to annotate them accordingly. In addition, we'll need some logic to tell what happens during this process.
Earlier we extracted editing functionality from Note
and ended up dropping Note
. It seems like we'll want to add that concept back if only for decoration purposes.
We can use a handy little technique here that allows us to avoid code duplication. We can implement Note
as a wrapper component. It will accept Editable
and render it. This will allow us to keep DnD related logic at Note
. This avoids having to duplicate any logic related to Editable
. The magic lies in a single property known as children
as seen in the implementation below. React will render possible child components at {this.props.children}
slot.
app/components/Note.jsx
import React from 'react';
export default class Note extends React.Component {
render() {
return (
<li {...this.props}>{this.props.children}</li>
);
}
}
We also need to tweak Notes
to use our wrapper component. We will simply wrap Editable
using Note
and we are good to go. We will pass note
data to the wrapper as we'll need that later when dealing with logic.
app/components/Notes.jsx
...
import Note from './Note.jsx';
export default class Notes extends React.Component {
...
renderNote(note) {
return (
<Note className='note' data={note} key={`note${note.id}`}>
<Editable
value={note.task}
onEdit={this.props.onEdit.bind(null, note.id)}
onDelete={this.props.onDelete.bind(null, note.id)} />
</Note>
);
}
}
After this change the application should look exactly same as before. We have achieved nothing yet. Fortunately, we can start adding functionality now that we have foundation in place.
React DnD uses constants to tell different draggables apart. Set up a file for tracking Note
as follows:
app/components/ItemTypes.js
export default {
NOTE: 'note'
};
We'll expand this definition later as we add new types to the system. Next, we need to tell our Note
that it's possible to drag and drop it.
We will be relying on DragSource
and DropTarget
decorators. In our case, Note
is both. After all we'll want to be able to sort them. Both decorators give us access to Note
props. In addition, we can access the source Note
through monitor.getItem()
at noteTarget
while props
map to target.
app/components/Note.jsx
...
import { DragSource, DropTarget } from 'react-dnd';
import ItemTypes from './ItemTypes';
const noteSource = {
beginDrag(props) {
console.log('begin dragging note', props);
return {};
}
};
const noteTarget = {
hover(targetProps, monitor) {
const sourceProps = monitor.getItem();
console.log('dragging note', sourceProps, targetProps);
}
};
@DragSource(ItemTypes.NOTE, noteSource, (connect) => ({
connectDragSource: connect.dragSource()
}))
@DropTarget(ItemTypes.NOTE, noteTarget, connect => ({
connectDropTarget: connect.dropTarget()
}))
export default class Note extends React.Component {
render() {
const { connectDragSource, connectDropTarget,
onMove, data, ...props } = this.props;
return connectDragSource(connectDropTarget(
<li {...props}>{props.children}</li>
));
}
}
If you drag a Note
now, you should see some debug prints at console. We are still missing some vital logic to make this all work.
W> Note that React DnD doesn't support hot loading perfectly. You may need to refresh browser to see prints you expect!
onMove
API for NotesIn order to make Note
operate based on id, we'll need to do a few things:
Note
data on beginDrag
Note
data on hover
hover
so that we can deal with the logic on higher levelYou can see how this translates to code below.
app/components/Note.jsx
...
const noteSource = {
beginDrag(props) {
return {
data: props.data
};
}
};
const noteTarget = {
hover(targetProps, monitor) {
const targetNote = targetProps.data || {};
const sourceProps = monitor.getItem();
const sourceNote = sourceProps.data || {};
if(sourceNote.id !== targetNote.id) {
targetProps.onMove({sourceNote, targetNote});
}
}
};
...
If you run the application now, you'll likely get a bunch of onMove
related errors. We should make Notes
aware of that.
app/components/Notes.jsx
...
export default class Notes extends React.Component {
...
renderNote(note) {
return (
<Note className='note' onMove={this.onMoveNote}
data={note} key={`note${note.id}`}>
<Editable
value={note.task}
onEdit={this.props.onEdit.bind(null, note.id)}
onDelete={this.props.onDelete.bind(null, note.id)} />
</Note>
);
}
onMoveNote({sourceNote, targetNote}) {
console.log('source', sourceNote, 'target', targetNote);
}
}
If you drag a Note
around now, you should see prints like source [Object] target [Object]
at console. We are getting close. We still need to figure out what to do with this data, though.
The logic of drag and drop is quite simple. Let's say we have a list A, B, C. In case we move A below C we should end up with B, C, A. In case we have another list, say D, E, F, and move A to the beginning of it, we should end up with B, C and A, D, E, F.
In our case, we'll get some extra complexity due to lane to lane dragging. Note that when we move a Note
we know its original position and the intended target position. Lane
knows what Notes
belong to it by id. We are going to need some way to tell LaneStore
that it should perform the logic over given notes. A good starting point is to define LaneActions.move
.
app/actions/LaneActions.jsx
import alt from '../libs/alt';
export default alt.generateActions(
'create', 'update', 'delete',
'attachToLane', 'detachFromLane',
'move'
);
We also need to trigger it when moving. We should connect this action with onMove
hook we just defined.
app/components/Notes.jsx
...
import LaneActions from '../actions/LaneActions';
export default class Notes extends React.Component {
...
renderNote(note) {
return (
<Note className='note' onMove={LaneActions.move}
data={note} key={`note${note.id}`}>
<Editable
value={note.task}
onEdit={this.props.onEdit.bind(null, note.id)} />
</Note>
);
}
}
We should also define a stub at LaneStore
to see that we wired it up correctly.
app/stores/LaneStore.jsx
...
class LaneStore {
...
move({sourceNote, targetNote}) {
console.log('source', sourceNote, 'target', targetNote);
}
}
export default alt.createStore(LaneStore, 'LaneStore');
You should see the same prints as earlier. Next, we'll need to add some logic to make this work. We can use the logic outlined above here. We have two cases to worry about. Moving within a lane itself and moving from lane to another.
Moving within a lane itself is more complicated. When you are operating based on ids and perform operations one at a time, you'll need to take possible index alterations in count. As a result, I'm using update
immutability helper from React as that solves the problem in one pass.
It is possible to solve the lane to lane case using splice. First we splice
out the source note and then we splice
it to the target lane. Again, update
could work here, but I didn't see much point in that given splice
is nice and simple.
Note that these operations will mutate our lanes
structure. At least we have the mutation contained now and it won't leak out of the store. It is possible to implement the same algorithm without mutation.
The code below illustrates mutation based solution.
app/stores/LaneStore.jsx
...
import update from 'react/lib/update';
export default class LaneStore {
...
move({sourceNote, targetNote}) {
const lanes = this.lanes;
const sourceId = sourceNote.id;
const targetId = targetNote.id;
const sourceLane = lanes.filter((lane) => {
return lane.notes.indexOf(sourceId) >= 0;
})[0];
const targetLane = lanes.filter((lane) => {
return lane.notes.indexOf(targetId) >= 0;
})[0];
const sourceNoteIndex = sourceLane.notes.indexOf(sourceId);
const targetNoteIndex = targetLane.notes.indexOf(targetId);
if(sourceLane === targetLane) {
// move at once to avoid complications
sourceLane.notes = update(sourceLane.notes, {
$splice: [
[sourceNoteIndex, 1],
[targetNoteIndex, 0, sourceId]
]
});
}
else {
// get rid of the source
sourceLane.notes.splice(sourceNoteIndex, 1);
// and move it to target
targetLane.notes.splice(targetNoteIndex, 0, sourceId);
}
this.setState({lanes});
}
}
If you try out the application now, you can actually drag notes around and it should behave as you expect. You cannot drag notes to an empty lane yet, though.
To drag notes to an empty lane we should allow lanes to receive notes. Just as above we can set up DropTarget
based logic for this. First we need to capture the drag on Lane
. It's the same idea as earlier.
app/components/Lane.jsx
...
import { DropTarget } from 'react-dnd';
import ItemTypes from './ItemTypes';
const noteTarget = {
hover(targetProps, monitor) {
const targetNote = targetProps.data || {};
const sourceProps = monitor.getItem();
const sourceNote = sourceProps.data || {};
console.log('source', sourceProps, 'target', targetProps);
}
};
@DropTarget(ItemTypes.NOTE, noteTarget, connect => ({
connectDropTarget: connect.dropTarget()
}))
export default class Lane extends React.Component {
render() {
const { connectDropTarget, id, name, notes, ...props } = this.props;
return connectDropTarget(
...
);
}
}
If you drag a note to a lane now, you should see prints at your console. The question is what to do with this data? Before actually moving the note to a lane we should check whether it's empty or not. If it has content already, the operation doesn't make sense. Our existing logic can deal with that.
This is a simple check to make. Given we know the target lane at our noteTarget
hover
handler, we can check its notes
array as below:
app/components/Lane.jsx
const noteTarget = {
hover(targetProps, monitor) {
const sourceProps = monitor.getItem();
const sourceNote = sourceProps.data || {};
if(!targetProps.notes.length) {
console.log('source', sourceProps, 'target', targetProps);
}
}
};
If you refresh your browser and drag around now, the print should appear only when you drag a note to a lane that doesn't have any notes attached to it yet.
Next, we'll need to trigger logic that can perform the move operation. We have some actions we can apply for this purpose. Remember those attach/detach actions we implemented earlier? To remind you of their signatures they look like this:
LaneStore.attachToLane({laneId, noteId})
LaneStore.detachFromLane({laneId, noteId})
By the looks of it we have enough data to perform attachToLane
. detachFromLane
is more problematic as we would need to know where to detach the note from. There are a couple of ways to solve this problem. We could pass lane id to Note
through the hierarchy. This doesn't feel particularly nice, though.
Instead, it feels more reasonable to solve this on store level. We can have the nasty logic there. Given a note can belong to only a single lane in our system we can enforce this rule at attachToLane
. We simply remove the note before attaching it should it exist somewhere within the system.
The noteTarget
part of this is simple. We need to trigger LaneActions.attachToLane
using the ids we know based on the data we have available.
app/components/Lane.jsx
const noteTarget = {
hover(targetProps, monitor) {
const sourceProps = monitor.getItem();
const sourceNote = sourceProps.data || {};
if(!targetProps.notes.length) {
LaneActions.attachToLane({
laneId: targetProps.id,
noteId: sourceNote.id
});
}
}
};
The store part is more complicated. I've separated the search and destroy part to a method of its own. Given we use search elsewhere it might be beneficial to refactor that to method as well. The code also relies on mutation which isn't particularly nice.
app/stores/LaneStore.jsx
...
class LaneStore {
...
attachToLane({laneId, noteId}) {
const lanes = this.lanes;
const targetId = this.findLane(laneId);
if(targetId < 0) {
return;
}
this.removeNote(noteId);
...
}
removeNote(noteId) {
const lanes = this.lanes;
const removeLane = lanes.filter((lane) => {
return lane.notes.indexOf(noteId) >= 0;
})[0];
if(!removeLane) {
return;
}
const removeNoteIndex = removeLane.notes.indexOf(noteId);
removeLane.notes = removeLane.notes.slice(0, removeNoteIndex).
concat(removeLane.notes.slice(removeNoteIndex + 1));
}
...
}
After these changes we have a Kanban table that is actually useful! We can create new lanes and notes, edit and remove them. In addition, we can move notes around. Mission accomplished!
In this chapter you saw how to implement drag and drop for our little application. You can model sorting for lanes using the same technique. First you mark the lanes to be draggable and droppable, then you sort out their ids and finally you'll add some logic to make it all work together. It should be considerably simpler than what we did with notes.
I encourage you to expand the application. The current implementation should work just as a starting point for something greater. Besides extending DnD implementation you can try adding more data to the system.
In the next chapter we'll set up a production level build for our application. You can use the same techniques in your own projects.