I'm using React DnD and Redux (using Kea) to build a formbuilder. I've the drag & drop portion working just fine, and I've managed to dispatch an action when an element drops, and I render the builder afterwards using state that the dispatch changed. However, in order to render elements in the correct order, I (think I) need to save the dropped elements position relative to it's siblings but I'm unable to figure out anything that isn't absolutely insane. I've experimented with refs and querying the DOM with the unique ID (I know I shouldn't), but both approaches feel pretty terrible and do not even work.
Here's a simplified representation of my app structure:
@DragDropContext(HTML5Backend)
@connect({ /* redux things */ })
<Builder>
<Workbench tree={this.props.tree} />
<Sidebar fields={this.props.field}/>
</Builder>
Workbench:
const boxTarget = {
drop(props, monitor, component) {
const item = monitor.getItem()
console.log(component, item.unique, component[item.unique]); // last one is undefined
window.component = component; // doing it manually works, so the element just isn't in the DOM yet
return {
key: 'workbench',
}
},
}
@DropTarget(ItemTypes.FIELD, boxTarget, (connect, monitor) => ({
connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}))
export default class Workbench extends Component {
render() {
const { tree } = this.props;
const { canDrop, isOver, connectDropTarget } = this.props
return connectDropTarget(
<div className={this.props.className}>
{tree.map((field, index) => {
const { key, attributes, parent, unique } = field;
if (parent === 'workbench') { // To render only root level nodes. I know how to render the children recursively, but to keep things simple...
return (
<Field
unique={unique}
key={key}
_key={key}
parent={this} // I'm passing the parent because the refs are useless in the Field instance (?) I don't know if this is a bad idea or not
/>
);
}
return null;
}).filter(Boolean)}
</div>,
)
// ...
Field:
const boxSource = {
beginDrag(props) {
return {
key: props._key,
unique: props.unique || shortid.generate(),
attributes: props.attributes,
}
},
endDrag(props, monitor) {
const item = monitor.getItem()
const dropResult = monitor.getDropResult()
console.log(dropResult);
if (dropResult) {
props.actions.onDrop({
item,
dropResult,
});
}
},
}
@connect({ /* redux stuff */ })
@DragSource(ItemTypes.FIELD, boxSource, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging(),
}))
export default class Field extends Component {
render() {
const { TagName, title, attributes, parent } = this.props
const { isDragging, connectDragSource } = this.props
const opacity = isDragging ? 0.4 : 1
return connectDragSource(
<div
className={classes.frame}
style={{opacity}}
data-unique={this.props.unique || false}
ref={(x) => parent[this.props.unique || this.props.key] = x} // If I save the ref to this instance, how do I access it in the drop function that works in context to boxTarget & Workbench?
>
<header className={classes.header}>
<span className={classes.headerName}>{title}</span>
</header>
<div className={classes.wrapper}>
<TagName {...attributes} />
</div>
</div>
)
}
}
Sidebar isn't very relevant.
My state is a flat array, consisting of objects that I can use to render the fields, so I'm reordering it based on the element positions in the DOM.
[
{
key: 'field_type1',
parent: 'workbench',
children: ['DAWPNC'], // If there's more children, "mutate" this according to the DOM
unique: 'AWJOPD',
attributes: {},
},
{
key: 'field_type2',
parent: 'AWJOPD',
children: false,
unique: 'DAWPNC',
attributes: {},
},
]
The relevant portion of this question revolves around
const boxTarget = {
drop(props, monitor, component) {
const item = monitor.getItem()
console.log(component, item.unique, component[item.unique]); // last one is undefined
window.component = component; // doing it manually works, so the element just isn't in the DOM yet
return {
key: 'workbench',
}
},
}
I figured I'd just get the reference to the element somehow, but it doesn't seem to exist in the DOM, yet. It's the same thing if I try to hack with ReactDOM:
// still inside the drop function, "works" with the timeout, doesn't without, but this is a bad idea
setTimeout(() => {
const domNode = ReactDOM.findDOMNode(component);
const itemEl = domNode.querySelector(`[data-unique="${item.unique}"]`);
const parentEl = itemEl.parentNode;
const index = Array.from(parentEl.children).findIndex(x => x.getAttribute('data-unique') === item.unique);
console.log(domNode, itemEl, index);
});
How do I achieve what I want?
Apologies for my inconsistent usage of semicolons, I don't know what I want from them. I hate them.
I think the key here is realizing that the Field
component can be both a DragSource
and a DropTarget
. We can then define a standard set of drop types that would influence how the state is mutated.
const DropType = {
After: 'DROP_AFTER',
Before: 'DROP_BEFORE',
Inside: 'DROP_INSIDE'
};
After
and Before
would allow re-ordering of fields, while Inside
would allow nesting of fields (or dropping into the workbench).
Now, the action creator for handling any drop would be:
const drop = (source, target, dropType) => ({
type: actions.DROP,
source,
target,
dropType
});
It just takes the source and target objects, and the type of drop occurring, which will then be translated into the state mutation.
A drop type is really just a function of the target bounds, the drop position, and (optionally) the drag source, all within the context of a particular DropTarget
type:
(bounds, position, source) => dropType
This function should be defined for each type of DropTarget
supported. This would allow each DropTarget
to support a different set of drop types. For instance, the Workbench
only knows how to drop something inside of itself, not before or after, so the implementation for the workbench could look like:
(bounds, position) => DropType.Inside
For a Field
, you could use the logic from the Simple Card Sort example, where the upper half of the DropTarget
translates to a Before
drop while the lower half translates to an After
drop:
(bounds, position) => {
const middleY = (bounds.bottom - bounds.top) / 2;
const relativeY = position.y - bounds.top;
return relativeY < middleY ? DropType.Before : DropType.After;
};
This approach also means that each DropTarget
could handle the drop()
spec method in the same manner:
With React DnD, we have to be careful to appropriately handle nested drop targets since we have Field
s in a Workbench
:
const configureDrop = getDropType => (props, monitor, component) => {
// a nested element handled the drop already
if (monitor.didDrop())
return;
// requires that the component attach the ref to a node property
const { node } = component;
if (!node) return;
const bounds = node.getBoundingClientRect();
const position = monitor.getClientOffset();
const source = monitor.getItem();
const dropType = getDropType(bounds, position, source);
if (!dropType)
return;
const { onDrop, ...target } = props;
onDrop(source, target, dropType);
// won't be used, but need to declare that the drop was handled
return { dropped: true };
};
The Component
class would end up looking something like this:
@connect(...)
@DragSource(ItemTypes.FIELD, {
beginDrag: ({ unique, parent, attributes }) => ({ unique, parent, attributes })
}, dragCollect)
// IMPORTANT: DropTarget has to be applied first so we aren't receiving
// the wrapped DragSource component in the drop() component argument
@DropTarget(ItemTypes.FIELD, {
drop: configureDrop(getFieldDropType)
canDrop: ({ parent }) => parent // don't drop if it isn't on the Workbench
}, dropCollect)
class Field extends React.Component {
render() {
return (
// ref prop used to provide access to the underlying DOM node in drop()
<div ref={ref => this.node = ref}>
// field stuff
</div>
);
}
Couple things to note:
Be mindful of the decorator order. DropTarget
should wrap the component, then DragSource
should wrap the wrapped component. This way, we have access to the correct component
instance inside drop()
.
The drop target's root node needs to be a native element node, not a custom component node.
Any component that will be decorated with the DropTarget
utilizing configureDrop()
will require that the component set its root node's DOM ref
to a node
property.
Since we are handling the drop in the DropTarget
, the DragSource
just needs to implement the beginDrag()
method, which would just return whatever state you want mixed into your application state.
The last thing to do is handle each drop type in your reducer. Important to remember is that every time you move something around, you need to remove the source from its current parent (if applicable), then insert it into the new parent. Each action could mutate the state of up to three elements, the source's existing parent (to clean up its children
), the source (to assign its parent
reference), and the target's parent or the target if an Inside
drop (to add
to its children
).
You also might want to consider making your state an object instead of an array, which might be easier to work with when implementing the reducer.
{
AWJOPD: { ... },
DAWPNC: { ... },
workbench: {
key: 'workbench',
parent: null,
children: [ 'DAWPNC' ]
}
}