Nuestra aplicación de Kanban es casi utilizable. Tiene un buen aspecto y cierta funcionalidad básica. En este capítulo integraremos la funcionalidad de arrastrar y soltar utilizando React DnD.
Al terminar este capítulo deberías ser capaz de arrastrar notas entre carriles. Aunque parezca sencillo implica realizar algo de trabajo por nuestra parte ya que tendremos que anotar los componentes de la forma correcta y crear la lógica necesaria.
Para comenzar necesitaremos conectar React DnD con nuestro proyecto. Vamos a utilizar un backend de arrastrar y soltar basado en el de HTML5. Existen backends específicos para testing y tacto.
Para configurarlo, necesitaremos utilizar el decorador DragDropContext
y facilitarle el backend de HTML5. Voy a utilizar compose
de redux para evitar revestimientos innecesarios y mantener el código más limpio:
app/components/App.jsx
import React from 'react';
import uuid from 'uuid';
import {compose} from 'redux';
import {DragDropContext} from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';
import connect from '../libs/connect';
import Lanes from './Lanes';
import LaneActions from '../actions/LaneActions';
const App = ({LaneActions, lanes}) => {
const addLane = () => {
LaneActions.create({
id: uuid.v4(),
name: 'New lane'
});
};
return (
<div>
<button className="add-lane" onClick={addLane}>+</button>
<Lanes lanes={lanes} />
</div>
);
};
export default connect(({lanes}) => ({
lanes
}), {
LaneActions
})(App)
export default compose(
DragDropContext(HTML5Backend),
connect(
({lanes}) => ({lanes}),
{LaneActions}
)
)(App)
Tras este cambio la aplicación debería tener el mismo aspecto de antes, pero ahora estamos preparados para añadir la funcionalidad.
Permitir que las notas puedan ser arrastradas es un buen comienzo. Antes de ello, necesitamos configurar una constante de tal modo que React DnD sepa que hay distintos tipos de elementos arrastrables. Crea un fichero con el que poder indicar que quieres mover elementos de tipo Nota
como sigue:
app/constants/itemTypes.js
export default {
NOTE: 'note'
};
Esta definición puede ser extendida más adelante incluyendo nuevos tipos, como CARRIL
, al sistema.
A continuación necesitamos decirle a nuestra Nota
que es posible arrastrarla. Esto se puede conseguir usando la anotación DragSource
. Modifica Nota
con la siguiente implementación:
app/components/Note.jsx
import React from 'react';
import {DragSource} from 'react-dnd';
import ItemTypes from '../constants/itemTypes';
const Note = ({
connectDragSource, children, ...props
}) => {
return connectDragSource(
<div {...props}>
{children}
</div>
);
};
const noteSource = {
beginDrag(props) {
console.log('begin dragging note', props);
return {};
}
};
export default DragSource(ItemTypes.NOTE, noteSource, connect => ({
connectDragSource: connect.dragSource()
}))(Note)
Deberías ver algo como esto en la consola del navegador al tratar de mover una nota:
begin dragging note Object {className: "note", children: Array[2]}
Ser capaz sólo de mover notas no es suficiente. Necesitamos anotarlas para que puedan ser soltadas. Esto nos permitirá lanzar cierta lógica cuando tratemos de soltar una nota encima de otra.
Observa que React DnD no soporta recarga en caliente perfectamente. Puede que necesites refrescar el navegador para ver los mensajes de log que esperas.
Podemos anotar notas de tal modo que detecten que otra nota les está pasando por encima de un modo similar al anterior. En este caso usaremos la anotación DropTarget
:
app/components/Note.jsx
import React from 'react';
import {DragSource} from 'react-dnd';
import {compose} from 'redux';
import {DragSource, DropTarget} from 'react-dnd';
import ItemTypes from '../constants/itemTypes';
const Note = ({
connectDragSource, children, ...props
connectDragSource, connectDropTarget,
children, ...props
}) => {
return connectDragSource(
return compose(connectDragSource, connectDropTarget)(
<div {...props}>
{children}
</div>
);
};
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);
}
};
export default DragSource(ItemTypes.NOTE, noteSource, connect => ({
connectDragSource: connect.dragSource()
}))(Note)
export default compose(
DragSource(ItemTypes.NOTE, noteSource, connect => ({
connectDragSource: connect.dragSource()
})),
DropTarget(ItemTypes.NOTE, noteTarget, connect => ({
connectDropTarget: connect.dropTarget()
}))
)(Note)
Si pruebas a arrastrar una nota por encima de otra deberías ver mensajes como el siguiente en la consola:
dragging note Object {} Object {className: "note", children: Array[2]}
Ambos decoradores nos dan acceso a las propiedades de Nota
. En este caso estamos usando monitor.getItem()
para acceder a ellas en noteTarget
. Esta es la clave para hacer que todo funcione correctamente.
onMove
para Notas
#Ahora que podemos mover las notas podemos comenzar a definir la lógica. Se necesitan los siguientes pasos:
Nota
en beginDrag
.Nota
objetivo hover
.hover
cuando se ejecute onMove
par que podamos incluir la lógica en algún sitio. LaneStore
puede ser el mejor lugar para ello.Siguiendo la idea anterior podemos pasar el identificador de la Nota
mediante una propiedad. También necesitaremos crear un esqueleto para la llamada a onMove
y definir LaneActions.move
y LaneStore.move
.
id
y onMove
en Nota
#Podemos aceptar las propiedades id
y onMove
en Nota
como sigue:
app/components/Note.jsx
...
const Note = ({
connectDragSource, connectDropTarget,
children, ...props
onMove, id, children, ...props
}) => {
return compose(connectDragSource, connectDropTarget)(
<div {...props}>
{children}
</div>
);
};
const noteSource = {
beginDrag(props) {
console.log('begin dragging note', props);
return {};
}
};
const noteSource = {
beginDrag(props) {
return {
id: props.id
};
}
};
const noteTarget = {
hover(targetProps, monitor) {
const sourceProps = monitor.getItem();
console.log('dragging note', sourceProps, targetProps);
}
};
const noteTarget = {
hover(targetProps, monitor) {
const targetId = targetProps.id;
const sourceProps = monitor.getItem();
const sourceId = sourceProps.id;
if(sourceId !== targetId) {
targetProps.onMove({sourceId, targetId});
}
}
};
...
Tener esas propiedades no es útil si no pasamos nada a Notas
. Ese será nuestro siguiente paso.
id
y onMove
desde Notes
#Pasar el id
de una nota y onMove
es sencillo:
app/components/Notes.jsx
import React from 'react';
import Note from './Note';
import Editable from './Editable';
export default ({
notes,
onNoteClick=() => {}, onEdit=() => {}, onDelete=() => {}
}) => (
<ul className="notes">{notes.map(({id, editing, task}) =>
<li key={id}>
<Note className="note" onClick={onNoteClick.bind(null, id)}>
<Note className="note" id={id}
onClick={onNoteClick.bind(null, id)}
onMove={({sourceId, targetId}) =>
console.log('moving from', sourceId, 'to', targetId)}>
<Editable
className="editable"
editing={editing}
value={task}
onEdit={onEdit.bind(null, id)} />
<button
className="delete"
onClick={onDelete.bind(null, id)}>x</button>
</Note>
</li>
)}</ul>
)
Si mueves una nota encima de otra verás mensajes por consola como el siguiente:
moving from 3310916b-5b59-40e6-8a98-370f9c194e16 to 939fb627-1d56-4b57-89ea-04207dbfb405
La lógica de arrastrar y soltar funciona como sigue. Supón que tienes un carril que contiene las notas A, B y C. En caso de que sitúes A detrás de C el carríl contendrá B, C y A. Si tienes otra lista, por ejemplo D, E y F, y movemos A al comienzo de ésta lista, acabaremos teniendo B y C y A, D, E y F.
En nuestro caso tendremos algo de complejidad extra al soltar notas de carril en carril. Cuando movamos una Nota
sabremos su posición original y la posición que querramos que tenga al final. El Carril
sabe qué Notas
le pertenecen por sus ids. Vamos a necesitar decir al LaneStore
de alguna forma que debe realizar algo de lógica sobre las notas que posee. Un buen punto de partida es definir LaneActions.move
:
app/actions/LaneActions.js
import alt from '../libs/alt';
export default alt.generateActions(
'create', 'update', 'delete',
'attachToLane', 'detachFromLane',
'move'
);
Debemos conectar esta acción con el punto de enganche onMove
que acabamos de definir:
app/components/Notes.jsx
import React from 'react';
import Note from './Note';
import Editable from './Editable';
import LaneActions from '../actions/LaneActions';
export default ({
notes,
onNoteClick=() => {}, onEdit=() => {}, onDelete=() => {}
}) => (
<ul className="notes">{notes.map(({id, editing, task}) =>
<li key={id}>
<Note className="note" id={id}
onClick={onNoteClick.bind(null, id)}
onMove={({sourceId, targetId}) =>
console.log('moving from', sourceId, 'to', targetId)}>
onMove={LaneActions.move}>
<Editable
className="editable"
editing={editing}
value={task}
onEdit={onEdit.bind(null, id)} />
<button
className="delete"
onClick={onDelete.bind(null, id)}>x</button>
</Note>
</li>
)}</ul>
)
Puede ser una buena idea refactorizaronMove
y dejarla como propiedad para hacer que el sistema sea más flexible. En nuestra implementación el componenteNotas
está acoplado conLaneActions
, lo cual no es particularmente útil si quieres poder usarlo en otro contexto.
También debemos definir un esqueleto en LaneStore
para ver que lo hemos cableado todo correctamente:
app/stores/LaneStore.js
import LaneActions from '../actions/LaneActions';
export default class LaneStore {
...
detachFromLane({laneId, noteId}) {
...
}
move({sourceId, targetId}) {
console.log(`source: ${sourceId}, target: ${targetId}`);
}
}
Deberías ver los mismos mensajes de log de antes.
A continuación vamos a añadir algo de lógica para conseguir que esto funcione. Hay dos casos de los que nos tenemos que preocupar: mover notas dentro de un mismo carril y mover notas entre distintos carriles.
El movimiento dentro de un mismo carril es complicado. Cuando estás basando las operaciones en ids y haces las operaciones una a una, tienes que tener en cuenta que puede hacer alteraciones en el índice. Como resultado estoy usando update de React para solucionar el problema de una pasada.
Es posible solucionar el caso de mover notas entre carriles usando splice. Primero obtenemos la nota a mover, y después la incorporamos al carril destino. De nuevo, update
puede ser útil aquí, aunque en este caso splice
está bien. El siguiente código muestra una posible solución:
app/stores/LaneStore.js
import update from 'react-addons-update';
import LaneActions from '../actions/LaneActions';
export default class LaneStore {
...
move({sourceId, targetId}) {
console.log(`source: ${sourceId}, target: ${targetId}`);
}
move({sourceId, targetId}) {
const lanes = this.lanes;
const sourceLane = lanes.filter(lane => lane.notes.includes(sourceId))[0];
const targetLane = lanes.filter(lane => lane.notes.includes(targetId))[0];
const sourceNoteIndex = sourceLane.notes.indexOf(sourceId);
const targetNoteIndex = targetLane.notes.indexOf(targetId);
if(sourceLane === targetLane) {
// las mueve en bloque para evitar complicaciones
sourceLane.notes = update(sourceLane.notes, {
$splice: [
[sourceNoteIndex, 1],
[targetNoteIndex, 0, sourceId]
]
});
}
else {
// elimina la nota del origen
sourceLane.notes.splice(sourceNoteIndex, 1);
// y la mueve al objetivo
targetLane.notes.splice(targetNoteIndex, 0, sourceId);
}
this.setState({lanes});
}
}
Si pruebas la aplicación ahora verás que puedes arrastrar notas y que el comportamiento debería ser el correcto. Arrastrar a carriles vacíos no funcionará y la presentación puede ser mejorada.
Podría ser mejor si indicásemos la localización de la nota arrastrada de forma más clara. Podemos conseguirlo ocultándola de la lista. React DnD nos dá los puntos de enganche que necesitamos para conseguirlo.
React DnD tiene una cualidad conocida como monitores de estado. Con ellos podemos usar monitor.isDragging()
y monitor.isOver()
para detectar qué Nota
es la que estamos arrastrando. Podemos configurarlo como sigue:
app/components/Note.jsx
import React from 'react';
import {compose} from 'redux';
import {DragSource, DropTarget} from 'react-dnd';
import ItemTypes from '../constants/itemTypes';
const Note = ({
connectDragSource, connectDropTarget,
onMove, id, children, ...props
connectDragSource, connectDropTarget, isDragging,
isOver, onMove, id, children, ...props
}) => {
return compose(connectDragSource, connectDropTarget)(
<div {...props}>
{children}
</div>
<div style={{
opacity: isDragging || isOver ? 0 : 1
}} {...props}>{children}</div>
);
};
...
export default compose(
DragSource(ItemTypes.NOTE, noteSource, connect => ({
connectDragSource: connect.dragSource()
})),
DropTarget(ItemTypes.NOTE, noteTarget, connect => ({
connectDropTarget: connect.dropTarget()
}))
DragSource(ItemTypes.NOTE, noteSource, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging()
})),
DropTarget(ItemTypes.NOTE, noteTarget, (connect, monitor) => ({
connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver()
}))
)(Note)
Si arrastras una nota por un carril, la nota arrastrada se mostrará en blanco.
Hay un pequeño problema con nuestro sistema. Todavía no podemos arrastrar notas sobre un carril vacío.
Para arrastrar notas sobre carriles vaciós necesitamos permitirles el poder recibir notas. Al igual que antes, podemos configurar una lógica basada en DropTarget
para ello. Antes de nada, necesitamos capturar el hecho de arrastrar en Carril
:
app/components/Lane.jsx
import React from 'react';
import {compose} from 'redux';
import {DropTarget} from 'react-dnd';
import ItemTypes from '../constants/itemTypes';
import connect from '../libs/connect';
import NoteActions from '../actions/NoteActions';
import LaneActions from '../actions/LaneActions';
import Notes from './Notes';
import LaneHeader from './LaneHeader';
const Lane = ({
lane, notes, LaneActions, NoteActions, ...props
connectDropTarget, lane, notes, LaneActions, NoteActions, ...props
}) => {
...
return (
return connectDropTarget(
...
);
};
function selectNotesByIds(allNotes, noteIds = []) {
...
}
const noteTarget = {
hover(targetProps, monitor) {
const sourceProps = monitor.getItem();
const sourceId = sourceProps.id;
// Si el carril destino no tiene notas
// le damos la nota.
//
// `attachToLane` hace la limpieza necesaria
// por defecto y garantiza que una nota sólo
// pueda pertenecar a un carril
if(!targetProps.lane.notes.length) {
LaneActions.attachToLane({
laneId: targetProps.lane.id,
noteId: sourceId
});
}
}
};
export default connect(
({notes}) => ({
notes
}), {
NoteActions,
LaneActions
}
)(Lane)
export default compose(
DropTarget(ItemTypes.NOTE, noteTarget, connect => ({
connectDropTarget: connect.dropTarget()
})),
connect(({notes}) => ({
notes
}), {
NoteActions,
LaneActions
})
)(Lane)
Debería ser capaz de poder arrastrar notas a carriles vacios una vez hayas añadido esta lógica.
Nuesta implementación de attachToLane
hace gran parte del trabajo duro por nosotros. Si no garantizase que una nota sólo puede pertenecer a un carril nuestra lógica debería ser modificada. Es bueno tener este tipo de certezas dentro del sistema de gestión de estados.
La implementación actual tiene un pequeño problema. Puedes arrastrar una nota mientras esta está siendo editada. Esto no es conveniente ya que no es lo que la mayoría de la gente espera poder hacer. No puedes, por ejemplo, hacer doble click en la caja de texto para seleccionar todo su contenido.
Por suerte es fácil de arreglar. Necesitamos usar el estado editing
de cada Nota
para ajustar su comportamiento. Lo primero que necesitamos es pasar el estado editing
a una Nota
individual:
app/components/Notes.jsx
import React from 'react';
import Note from './Note';
import Editable from './Editable';
import LaneActions from '../actions/LaneActions';
export default ({
notes,
onNoteClick=() => {}, onEdit=() => {}, onDelete=() => {}
}) => (
<ul className="notes">{notes.map(({id, editing, task}) =>
<li key={id}>
<Note className="note" id={id}
editing={editing}
onClick={onNoteClick.bind(null, id)}
onMove={LaneActions.move}>
<Editable
className="editable"
editing={editing}
value={task}
onEdit={onEdit.bind(null, id)} />
<button
className="delete"
onClick={onDelete.bind(null, id)}>x</button>
</Note>
</li>
)}</ul>
)
Lo siguiente será tenerlo en cuenta a la hora de renderizar:
app/components/Note.jsx
import React from 'react';
import {compose} from 'redux';
import {DragSource, DropTarget} from 'react-dnd';
import ItemTypes from '../constants/itemTypes';
const Note = ({
connectDragSource, connectDropTarget, isDragging,
isOver, onMove, id, children, ...props
isOver, onMove, id, editing, children, ...props
}) => {
// Pass through if we are editing
const dragSource = editing ? a => a : connectDragSource;
return compose(connectDragSource, connectDropTarget)(
return compose(dragSource, connectDropTarget)(
<div style={{
opacity: isDragging || isOver ? 0 : 1
}} {...props}>{children}</div>
);
};
...
Este pequeño cambio nos dá el comportamiento que queremos. Si tratas de editar una nota ahora, la caja de texto se comportará como esperas.
Mirando hacia atrás podemos ver que mantener el estado editing
fuera de Editable
fue una buena idea. Si no lo hubiésemos hecho así, implementar este cambio habría sido bastante más difícil ya que tendríamos que poder sacar el estado fuera del componente.
¡Por fin tenemos un tablero Kanban que es útil!. Podemos crear carriles y notas nuevas, y también podemos editarlas y borrarlas. Además podemos movar las notas. ¡Objetivo cumplido!
En este capítulo has visto cómo implementar la funcionalidad de arrastrar y soltar para nuestra pequeña aplicación. Puedes modelar la ordenación de carriles usando la misma técnica. Primero, marcas los carriles como arrastrables y soltables, los ordenas según sus identificadores y, finalmente, añades algo de lógica para hacer que todo funcione. Debería ser más sencillo que lo que hemos hecho con las notas.
Te animo a que hagas crecer la aplicación. La implementación actual debería servir de punto de entrada para hacer algo más grande. Más allá de la implementación de arrastrar y soltar, puedes tratar de añadir más datos al sistema. También puedes hacer algo con el aspecto gráfico. Una opción puede ser usar varias de las aproximaciones de aplicación de estilos que se discuten en el capítulo Dando Estilo a React.
Para conseguir que sea difícil romper la aplicación durante el desarrollo, puedes implementar tests como se indica en Probando React. Tipando con React discute más modos aún de endurecer tu código. Aprender estas aproximaciones puede merecer la pena. A veces es realmente útil diseñar antes los tests de las aplicaciones, ya que es una aporximación valiosa que te permite documentar lo que vas asumiendo a medida que haces la implementación.
Puedes encontrar este libro en Leanpub. Comprando este libro permitirás el desarrollo de más contenido.