Make dashcards reorder on drag and drop

NOTE: This commit does not make it so that the data
            persists after refreshes.

closes CNVS-32548

Test Plan:
  - Enable dashboard card reordering feature flag
  - Go to a dashboard with multiple dashcards
  - You should be able to move them around via drag
    and drop.

Change-Id: I66b91b30fd619516558841a6867ef857a33b890a
Reviewed-on: https://gerrit.instructure.com/92616
Reviewed-by: Felix Milea-Ciobanu <fmileaciobanu@instructure.com>
Tested-by: Jenkins
QA-Review: Benjamin Christian Nelson <bcnelson@instructure.com>
Product-Review: Clay Diffrient <cdiffrient@instructure.com>
This commit is contained in:
Clay Diffrient 2016-10-11 20:18:57 -06:00
parent c49e2560a6
commit 56019ec813
13 changed files with 5364 additions and 21 deletions

View File

@ -4,9 +4,15 @@ require [
'react'
'react-dom'
'jsx/dashboard_card/DashboardCardBox',
], ($, _, React, ReactDOM, DashboardCardBox) ->
element = React.createElement(DashboardCardBox, {
courseCards: ENV.DASHBOARD_COURSES
'jsx/dashboard_card/getDroppableDashboardCardBox',
], ($, _, React, ReactDOM, DashboardCardBox, getDroppableDashboardCardBox) ->
component = if ENV.DASHBOARD_REORDERING_ENABLED then getDroppableDashboardCardBox() else DashboardCardBox
element = React.createElement(component, {
courseCards: ENV.DASHBOARD_COURSES,
reorderingEnabled: ENV.DASHBOARD_REORDERING_ENABLED
})
dashboardContainer = document.getElementById('DashboardCard_Container')
ReactDOM.render(element, dashboardContainer)

View File

@ -4,8 +4,11 @@ define([
'i18n!dashcards',
'./DashboardCardAction',
'./DashboardColorPicker',
'./CourseActivitySummaryStore'
], function(_, React, I18n, DashboardCardAction, DashboardColorPicker, CourseActivitySummaryStore) {
'./CourseActivitySummaryStore',
'react-dnd',
'./Types',
'jsx/shared/helpers/compose'
], function(_, React, I18n, DashboardCardAction, DashboardColorPicker, CourseActivitySummaryStore, ReactDnD, ItemTypes, compose) {
var DashboardCard = React.createClass({
@ -23,7 +26,11 @@ define([
assetString: React.PropTypes.string,
term: React.PropTypes.string,
href: React.PropTypes.string,
links: React.PropTypes.array
links: React.PropTypes.array,
reorderingEnabled: React.PropTypes.bool,
isDragging: React.PropTypes.bool,
connectDragSource: React.PropTypes.func,
connectDropTarget: React.PropTypes.func
},
getDefaultProps: function () {
@ -196,11 +203,21 @@ define([
},
render: function () {
return (
const cardStyles = {
borderBottomColor: this.props.backgroundColor
};
if (this.props.reorderingEnabled) {
if (this.props.isDragging) {
cardStyles.opacity = 0;
}
}
const dashboardCard = (
<div
className="ic-DashboardCard"
ref="cardDiv"
style={{borderBottomColor: this.props.backgroundColor}}
ref={(c) => this.cardDiv = c}
style={cardStyles}
aria-label={this.props.originalName}
>
<div className="ic-DashboardCard__header">
@ -209,7 +226,7 @@ define([
this.props.imagesEnabled && this.props.image ?
I18n.t("Course image for %{course}", {course: this.state.nicknameInfo.nickname})
:
I18n.t("Course card color region for %{course}", {course: this.state.nicknameInfo.nickname})
I18n.t("Course card color region for %{course}", {course: this.state.nicknameInfo.nickname})
}
</span>
{this.renderHeaderHero()}
@ -251,6 +268,13 @@ define([
{ this.colorPickerIfEditing() }
</div>
);
if (this.props.reorderingEnabled) {
const { connectDragSource, connectDropTarget } = this.props;
return connectDragSource(connectDropTarget(dashboardCard));
}
return dashboardCard;
}
});

View File

@ -2,14 +2,23 @@ define([
'jquery',
'react',
'./DashboardCard',
'./DashboardCardBackgroundStore'
], function($, React, DashboardCard, DashboardCardBackgroundStore) {
var DashboardCardBox = React.createClass({
'./DraggableDashboardCard',
'./DashboardCardBackgroundStore',
], ($, React, DashboardCard, DraggableDashboardCard, DashboardCardBackgroundStore) => {
const DashboardCardBox = React.createClass({
displayName: 'DashboardCardBox',
propTypes: {
courseCards: React.PropTypes.array
courseCards: React.PropTypes.array,
reorderingEnabled: React.PropTypes.bool,
connectDropTarget: React.PropTypes.func
},
componentWillMount () {
this.setState({
courseCards: this.props.courseCards
});
},
componentDidMount: function(){
@ -17,8 +26,12 @@ define([
DashboardCardBackgroundStore.setDefaultColors(this.allCourseAssetStrings());
},
componentWillReceiveProps: function(){
componentWillReceiveProps: function (newProps) {
DashboardCardBackgroundStore.setDefaultColors(this.allCourseAssetStrings());
this.setState({
courseCards: newProps.courseCards
});
},
getDefaultProps: function () {
@ -27,6 +40,16 @@ define([
};
},
moveCard (assetString, atIndex) {
const cardIndex = this.state.courseCards.findIndex(card => card.assetString === assetString);
const newCards = this.state.courseCards.slice();
newCards.splice(atIndex, 0, newCards.splice(cardIndex, 1)[0]);
this.setState({
courseCards: newCards
});
},
colorsUpdated: function(){
if(this.isMounted()){
this.forceUpdate();
@ -45,10 +68,16 @@ define([
DashboardCardBackgroundStore.setColorForCourse(assetString, newColor);
},
getOriginalIndex (assetString) {
return this.props.courseCards.findIndex(c => c.assetString === assetString);
},
render: function () {
var cards = this.props.courseCards.map((card) => {
const Component = (this.props.reorderingEnabled) ? DraggableDashboardCard : DashboardCard;
const cards = this.state.courseCards.map((card, index) => {
const position = (card.position != null) ? card.position : this.getOriginalIndex.bind(this, card.assetString)
return (
<DashboardCard
<Component
key={card.id}
shortName={card.shortName}
originalName={card.originalName}
@ -62,14 +91,26 @@ define([
handleColorChange={this.handleColorChange.bind(this, card.assetString)}
image={card.image}
imagesEnabled={card.imagesEnabled}
reorderingEnabled={this.props.reorderingEnabled}
position={position}
currentIndex={index}
moveCard={this.moveCard}
/>
);
});
return (
const dashboardCardBox = (
<div className="ic-DashboardCard__box">
{cards}
</div>
);
if (this.props.reorderingEnabled) {
const { connectDropTarget } = this.props;
return connectDropTarget(dashboardCardBox);
}
return dashboardCardBox;
}
});

View File

@ -0,0 +1,51 @@
define([
'react-dnd',
'jsx/shared/helpers/compose',
'./Types',
'./DashboardCard'
], ({ DropTarget, DragSource }, compose, ItemTypes, DashboardCard) => {
const cardSource = {
beginDrag(props) {
return {
assetString: props.assetString,
originalIndex: props.currentIndex
};
},
isDragging(props, monitor) {
return monitor.getItem().assetString === props.assetString;
},
endDrag(props, monitor) {
const { assetString: draggedAssetString } = monitor.getItem();
if (!monitor.didDrop()) {
props.moveCard(draggedAssetString, props.position);
}
// TODO: Call something to actually move things to the right positions on the server
}
};
const cardTarget = {
canDrop() {
return false;
},
hover(props, monitor) {
const { assetString: draggedAssetString } = monitor.getItem();
const { assetString: overAssetString } = props;
if (draggedAssetString !== overAssetString) {
const { currentIndex: overIndex } = props;
props.moveCard(draggedAssetString, overIndex);
}
}
};
/* eslint-disable new-cap */
return compose(
DropTarget(ItemTypes.CARD, cardTarget, connect => ({
connectDropTarget: connect.dropTarget()
})),
DragSource(ItemTypes.CARD, cardSource, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging()
}))
)(DashboardCard);
/* eslint-enable new-cap */
});

View File

@ -0,0 +1,10 @@
define([], () => {
/**
* Used for drag and drop stuff
*/
const TYPES = {
CARD: 'card'
};
return TYPES;
});

View File

@ -0,0 +1,24 @@
define([
'react-dnd',
'react-dnd-html5-backend',
'jsx/shared/helpers/compose',
'./Types',
'./DashboardCardBox'
], ({ DragDropContext, DropTarget }, ReactDnDHTML5Backend, compose, ItemTypes, DashboardCardBox) => {
const cardTarget = {
drop () {}
};
const getDroppableDashboardCardBox = (backend = ReactDnDHTML5Backend) => (
/* eslint-disable new-cap */
compose(
DragDropContext(backend),
DropTarget(ItemTypes.CARD, cardTarget, connect => ({
connectDropTarget: connect.dropTarget()
}))
)(DashboardCardBox)
/* eslint-enable new-cap */
);
return getDroppableDashboardCardBox;
});

View File

@ -0,0 +1,32 @@
define([], () => {
// Adapted from https://github.com/reactjs/redux/blob/master/src/compose.js
// This is a simple utility to help with composing functions together
/**
* Composes single-argument functions from right to left. The rightmost
* function can take multiple arguments as it provides the signature for
* the resulting composite function.
*
* @param {...Function} funcs The functions to compose.
* @returns {Function} A function obtained by composing the argument functions
* from right to left. For example, compose(f, g, h) is identical to doing
* (...args) => f(g(h(...args))).
*/
function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
const last = funcs[funcs.length - 1]
const rest = funcs.slice(0, -1)
return (...args) => rest.reduceRight((composed, f) => f(composed), last(...args))
}
return compose;
});

View File

@ -1,9 +1,13 @@
<% js_bundle :dashboard_card %>
<% css_bundle :dashboard_card %>
<%
<%
dashboard_courses = map_courses_for_menu(@current_user.menu_courses, :include_section_tabs => true)
js_env :DASHBOARD_COURSES => dashboard_courses %>
js_env({
:DASHBOARD_COURSES => dashboard_courses,
:DASHBOARD_REORDERING_ENABLED => @domain_root_account.feature_enabled?(:dashcard_reordering)
})
%>
<div id="DashboardCard_Container" style="display: <%= show_recent_activity? ? 'none' : 'block' %>">
<div class="ic-DashboardCard__box">

View File

@ -79,6 +79,8 @@ module Canvas
{name: 'qs', location: 'symlink_to_node_modules/qs', main: 'dist/qs'},
{name: 'react', location: 'symlink_to_node_modules/react', main: 'dist/react-with-addons'},
{name: 'react-dom', location: 'symlink_to_node_modules/react-dom', main: 'dist/react-dom'},
{name: 'react-dnd', location: 'symlink_to_node_modules/react-dnd', main: 'dist/ReactDnD.min'},
{name: 'react-dnd-html5-backend', location: 'symlink_to_node_modules/react-dnd-html5-backend', main: 'dist/ReactDnDHTML5Backend.min'},
{name: 'react-redux', location: 'symlink_to_node_modules/react-redux', main: 'dist/react-redux.min'},
{name: 'react-select-box', location: 'symlink_to_node_modules/react-select-box', main: 'dist/react-select-box'},
{name: 'react-tokeninput', location: 'symlink_to_node_modules/react-tokeninput', main: 'dist/react-tokeninput'},

View File

@ -88,6 +88,8 @@
"qs": "https://github.com/hapijs/qs.git#a341cdf2fadba5ede1ce6c95c7051f6f31f37b81",
"react": "0.14.8",
"react-dom": "0.14.8",
"react-dnd": "2.1.4",
"react-dnd-html5-backend": "2.1.2",
"react-addons-css-transition-group": "0.14.8",
"react-addons-pure-render-mixin": "0.14.8",
"react-addons-test-utils": "0.14.8",

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,95 @@
define([
'react',
'react-dom',
'react-addons-test-utils',
'react-dnd',
'react-dnd-test-backend',
'jsx/dashboard_card/DraggableDashboardCard',
'jsx/dashboard_card/getDroppableDashboardCardBox',
'jsx/dashboard_card/DashboardCardBox',
'helpers/fakeENV'
], (React, ReactDOM, TestUtils, { DragDropContext }, ReactDndTestBackend, DraggableDashboardCard, getDroppableDashboardCardBox, DashboardCardBox, fakeENV) => {
let cards;
let fakeServer;
module('DashboardCard Reordering', {
setup () {
fakeENV.setup({
DASHBOARD_REORDERING_ENABLED: true
});
cards = [{
id: 1,
assetString: 'course_1',
position: 0,
originalName: 'Intro to Dashcards 1',
shortName: 'Dash 101'
}, {
id: 2,
assetString: 'course_2',
position: 1,
originalName: 'Intermediate Dashcarding',
shortName: 'Dash 201'
}, {
id: 3,
assetString: 'course_3',
originalName: 'Advanced Dashcards',
shortName: 'Dash 301'
}];
fakeServer = sinon.fakeServer.create();
},
teardown () {
fakeENV.teardown();
cards = null;
fakeServer.restore();
}
});
test('it renders', () => {
const Box = getDroppableDashboardCardBox()
const root = TestUtils.renderIntoDocument(
<Box reorderingEnabled courseCards={cards} />
);
ok(root);
});
test('cards have opacity of 0 while moving', () => {
const Card = DraggableDashboardCard.DecoratedComponent.DecoratedComponent;
const card = TestUtils.renderIntoDocument(
<Card
{...cards[0]}
connectDragSource={el => el}
connectDropTarget={el => el}
isDragging
reorderingEnabled
/>
);
const div = TestUtils.findRenderedDOMComponentWithClass(card, 'ic-DashboardCard')
equal(div.style.opacity, 0);
});
test('moving a card adjusts the position property', () => {
const Box = getDroppableDashboardCardBox(ReactDndTestBackend);
const root = TestUtils.renderIntoDocument(
<Box
reorderingEnabled
courseCards={cards}
connectDropTarget={el => el}
/>
);
const backend = root.getManager().getBackend();
const renderedCardComponents = TestUtils.scryRenderedComponentsWithType(root, DraggableDashboardCard);
const sourceHandlerId = renderedCardComponents[0].getDecoratedComponentInstance().getHandlerId();
const targetHandlerId = renderedCardComponents[1].getHandlerId()
backend.simulateBeginDrag([sourceHandlerId]);
backend.simulateHover([targetHandlerId]);
backend.simulateDrop();
const renderedAfterDragNDrop = TestUtils.scryRenderedDOMComponentsWithClass(root, 'ic-DashboardCard')
equal(renderedAfterDragNDrop[0].getAttribute('aria-label'), 'Intermediate Dashcarding')
equal(renderedAfterDragNDrop[1].getAttribute('aria-label'), 'Intro to Dashcards 1')
})
});

View File

@ -30,4 +30,4 @@ var require = {
location: '../../spec'
},
].concat(<%= packages %>)
};
};