Replace TagPanel react-dnd with react-beautiful-dnd

This new library handles the simple case of an ordered vertical
(or horizontal) list of items that can be reordered.

It provides animations, handles positioning of items mid-drag
and exposes a much simpler API to react-dnd (with a slight loss
of potential function, but we don't need this flexibility here
anyway).

Apart from this, TagOrderStore had to be changed in a highly
coupled way, but arguably for the better. Instead of being
updated incrementally every time an item is dragged over
another and having a separate "commit" action, the
asyncronous action `moveTag` is used to reposition the tag in
the list and both dispatch an optimistic update and carry out
the request as before. (The MatrixActions.accountData is still
used to indicate a successful reordering of tags).

The view is updated instantly, in an animated way, and this
is handled at the layer "above" React by the DND library.
pull/21833/head
lukebarnard 2018-01-15 18:12:27 +00:00
parent 952f2c6a21
commit 16c13fb079
6 changed files with 85 additions and 100 deletions

View File

@ -77,6 +77,7 @@
"querystring": "^0.2.0", "querystring": "^0.2.0",
"react": "^15.4.0", "react": "^15.4.0",
"react-addons-css-transition-group": "15.3.2", "react-addons-css-transition-group": "15.3.2",
"react-beautiful-dnd": "^4.0.0",
"react-dnd": "^2.1.4", "react-dnd": "^2.1.4",
"react-dnd-html5-backend": "^2.1.2", "react-dnd-html5-backend": "^2.1.2",
"react-dom": "^15.4.0", "react-dom": "^15.4.0",

View File

@ -31,16 +31,22 @@ const TagOrderActions = {};
* indicating the status of the request. * indicating the status of the request.
* @see asyncAction * @see asyncAction
*/ */
TagOrderActions.commitTagOrdering = function(matrixClient) { TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
return asyncAction('TagOrderActions.commitTagOrdering', () => { // Only commit tags if the state is ready, i.e. not null
// Only commit tags if the state is ready, i.e. not null let tags = TagOrderStore.getOrderedTags();
const tags = TagOrderStore.getOrderedTags(); if (!tags) {
if (!tags) { return;
return; }
}
tags = tags.filter((t) => t !== tag);
tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)];
return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
return matrixClient.setAccountData('im.vector.web.tag_ordering', {tags}); return matrixClient.setAccountData('im.vector.web.tag_ordering', {tags});
}, () => {
// For an optimistic update
return {tags};
}); });
}; };

View File

@ -22,6 +22,9 @@ limitations under the License.
* suffix determining whether it is pending, successful or * suffix determining whether it is pending, successful or
* a failure. * a failure.
* @param {function} fn a function that returns a Promise. * @param {function} fn a function that returns a Promise.
* @param {function?} pendingFn a function that returns an object to assign
* to the `request` key of the ${id}.pending
* payload.
* @returns {function} an action thunk - a function that uses its single * @returns {function} an action thunk - a function that uses its single
* argument as a dispatch function to dispatch the * argument as a dispatch function to dispatch the
* following actions: * following actions:
@ -29,9 +32,13 @@ limitations under the License.
* `${id}.success` or * `${id}.success` or
* `${id}.failure`. * `${id}.failure`.
*/ */
export function asyncAction(id, fn) { export function asyncAction(id, fn, pendingFn) {
return (dispatch) => { return (dispatch) => {
dispatch({action: id + '.pending'}); dispatch({
action: id + '.pending',
request:
typeof pendingFn === 'function' ? pendingFn() : undefined,
});
fn().then((result) => { fn().then((result) => {
dispatch({action: id + '.success', result}); dispatch({action: id + '.success', result});
}).catch((err) => { }).catch((err) => {

View File

@ -25,6 +25,8 @@ import TagOrderActions from '../../actions/TagOrderActions';
import sdk from '../../index'; import sdk from '../../index';
import dis from '../../dispatcher'; import dis from '../../dispatcher';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
const TagPanel = React.createClass({ const TagPanel = React.createClass({
displayName: 'TagPanel', displayName: 'TagPanel',
@ -69,7 +71,9 @@ const TagPanel = React.createClass({
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
}, },
onClick() { onClick(e) {
// Ignore clicks on children
if (e.target !== e.currentTarget) return;
dis.dispatch({action: 'deselect_tags'}); dis.dispatch({action: 'deselect_tags'});
}, },
@ -78,8 +82,20 @@ const TagPanel = React.createClass({
dis.dispatch({action: 'view_create_group'}); dis.dispatch({action: 'view_create_group'});
}, },
onTagTileEndDrag() { onTagTileEndDrag(result) {
dis.dispatch(TagOrderActions.commitTagOrdering(this.context.matrixClient)); // Dragged to an invalid destination, not onto a droppable
if (!result.destination) {
return;
}
// Dispatch synchronously so that the TagPanel receives an
// optimistic update from TagOrderStore before the previous
// state is shown.
dis.dispatch(TagOrderActions.moveTag(
this.context.matrixClient,
result.draggableId,
result.destination.index,
), true);
}, },
render() { render() {
@ -89,16 +105,26 @@ const TagPanel = React.createClass({
const tags = this.state.orderedTags.map((tag, index) => { const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile return <DNDTagTile
key={tag + '_' + index} key={tag}
tag={tag} tag={tag}
index={index}
selected={this.state.selectedTags.includes(tag)} selected={this.state.selectedTags.includes(tag)}
onEndDrag={this.onTagTileEndDrag}
/>; />;
}); });
return <div className="mx_TagPanel" onClick={this.onClick}> return <div className="mx_TagPanel" onClick={this.onClick}>
<div className="mx_TagPanel_tagTileContainer"> <DragDropContext onDragEnd={this.onTagTileEndDrag}>
{ tags } <Droppable droppableId="tag-panel-droppable">
</div> { (provided, snapshot) => (
<div
className="mx_TagPanel_tagTileContainer"
ref={provided.innerRef}
>
{ tags }
{ provided.placeholder }
</div>
) }
</Droppable>
</DragDropContext>
<AccessibleButton className="mx_TagPanel_createGroupButton" onClick={this.onCreateGroupClick}> <AccessibleButton className="mx_TagPanel_createGroupButton" onClick={this.onCreateGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="25" height="25" /> <TintableSvg src="img/icons-create-room.svg" width="25" height="25" />
</AccessibleButton> </AccessibleButton>

View File

@ -15,71 +15,29 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { DragSource, DropTarget } from 'react-dnd';
import TagTile from './TagTile'; import TagTile from './TagTile';
import dis from '../../../dispatcher';
import { findDOMNode } from 'react-dom';
const tagTileSource = { import { Draggable } from 'react-beautiful-dnd';
canDrag: function(props, monitor) {
return true;
},
beginDrag: function(props) { export default function DNDTagTile(props) {
// Return the data describing the dragged item return <div>
return { <Draggable
tag: props.tag, key={props.tag}
}; draggableId={props.tag}
}, index={props.index}
>
endDrag: function(props, monitor, component) { { (provided, snapshot) => (
const dropResult = monitor.getDropResult(); <div>
if (!monitor.didDrop() || !dropResult) { <div
return; ref={provided.innerRef}
} {...provided.draggableProps}
props.onEndDrag(); {...provided.dragHandleProps}
}, >
}; <TagTile {...props} />
</div>
const tagTileTarget = { { provided.placeholder }
canDrop(props, monitor) { </div>
return true; ) }
}, </Draggable>
</div>;
hover(props, monitor, component) { }
if (!monitor.canDrop()) return;
const draggedY = monitor.getClientOffset().y;
const {top, bottom} = findDOMNode(component).getBoundingClientRect();
const targetY = (top + bottom) / 2;
dis.dispatch({
action: 'order_tag',
tag: monitor.getItem().tag,
targetTag: props.tag,
// Note: we indicate that the tag should be after the target when
// it's being dragged over the top half of the target.
after: draggedY < targetY,
});
},
drop(props) {
// Return the data to be returned by getDropResult
return {
tag: props.tag,
};
},
};
export default
DropTarget('TagTile', tagTileTarget, (connect, monitor) => ({
connectDropTarget: connect.dropTarget(),
}))(DragSource('TagTile', tagTileSource, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
}))((props) => {
const { connectDropTarget, connectDragSource, ...otherProps } = props;
return connectDropTarget(connectDragSource(
<div>
<TagTile {...otherProps} />
</div>,
));
}));

View File

@ -78,24 +78,11 @@ class TagOrderStore extends Store {
this._updateOrderedTags(); this._updateOrderedTags();
break; break;
} }
// Puts payload.tag at payload.targetTag, placing the targetTag before or after the tag case 'TagOrderActions.moveTag.pending': {
case 'order_tag': { // Optimistic update of a moved tag
if (!this._state.orderedTags || this._setState({
!payload.tag || orderedTags: payload.request.tags,
!payload.targetTag || });
payload.tag === payload.targetTag
) return;
const tags = this._state.orderedTags;
let orderedTags = tags.filter((t) => t !== payload.tag);
const newIndex = orderedTags.indexOf(payload.targetTag) + (payload.after ? 1 : 0);
orderedTags = [
...orderedTags.slice(0, newIndex),
payload.tag,
...orderedTags.slice(newIndex),
];
this._setState({orderedTags});
break; break;
} }
case 'select_tag': { case 'select_tag': {