Drag and drop within pinned container

fixes COMMS-951

Test Plan:
- With SSA on
- Go to the announcements index page
- notice you can create an announcement
- notice the new activity indicator is there
- notice there are no console errors
- go to the groups announcements page
- repeat the first 4 steps

- With SSD on go to the discussions index page
- Start dragging and dropping between pinned, unpinned, ccc
- notice everything updates correctly
- now for the magic
NOTE: you cannot drag from unpinned discussions or from ccc
  straight to a position in pinned container.  This is expected
- within the pinned container drag the discussions around and drop them
  in the places you expect
- notice after page refresh everything sticks
- in the api client on line 84 change api/v1 to api/v2
- start dragging within the container notice things go back to where
they were on fail
- repeat this after changing line 50 as well
- notice everything fails correctly
- dont' worry about a11y because its not accessible
- party because if you got to here then things work and we can all go
home
Change-Id: I1856bf4792780c2ab54df4cfbd180b44c79f5269
Reviewed-on: https://gerrit.instructure.com/143875
Reviewed-by: Landon Gilbert-Bland <lbland@instructure.com>
Tested-by: Jenkins
Product-Review: Aaron Kc Hsu <ahsu@instructure.com>
QA-Review: Aaron Kc Hsu <ahsu@instructure.com>
This commit is contained in:
Steven Burnett 2018-03-16 13:00:10 -06:00 committed by Aaron Kc Hsu
parent cc2f39ad36
commit 61f102711f
16 changed files with 799 additions and 358 deletions

View File

@ -31,6 +31,9 @@ const discussionActions = createPaginationActions('discussions', apiClient.getDi
const types = [
...discussionActions.actionTypes,
'DRAG_AND_DROP_START',
'DRAG_AND_DROP_SUCCESS',
'DRAG_AND_DROP_FAIL',
'UPDATE_DISCUSSIONS_SEARCH',
'TOGGLE_MODAL_OPEN',
'TOGGLE_SUBSCRIBE_START',
@ -86,6 +89,8 @@ const defaultFailMessage = I18n.t('Updating discussion failed')
// focusOn must be one of 'title' or 'manageMenu' (or can be left unspecified)
// If set to a value, it will cause focus to end up on the title or manage menu
// of the updated discussion.
// TODO change this to the onSuccess paradigm. Much easier
actions.updateDiscussion = function(discussion, updatedFields, { successMessage, failMessage }, focusOn) {
return (dispatch, getState) => {
const discussionCopy = copyAndUpdateDiscussion(discussion, updatedFields, focusOn)
@ -109,6 +114,57 @@ actions.updateDiscussion = function(discussion, updatedFields, { successMessage,
}
}
// We need to assume success here, so that when we drag and drop something
// it does not snap back to its current location and then move to the
// correct location after the API call succeeds. This is a bit more complex
// as we could be making two API calls here as well (pinning a discussion
// and setting the order of the pin). Start by updating the store with
// this information, then rollback based on if either of the API calls
// failed.
actions.handleDrop = function(discussion, updatedFields, order) {
return (dispatch, getState) => {
const originalOrder = order ? getState().pinnedDiscussions.map(d => d.id) : undefined
const discussionCopy = copyAndUpdateDiscussion(discussion, updatedFields)
dispatch(actions.dragAndDropStart({discussion: discussionCopy, order}))
apiClient.updateDiscussion(getState(), discussion, updatedFields)
.then(() => {
// Only need to make this API call if reordering the pinned discussions
const promise = (discussionCopy.pinned && order !== undefined)
? apiClient.reorderPinnedDiscussions(getState(), order)
: new Promise(resolve => resolve())
promise
.then(() => {
dispatch(actions.dragAndDropSuccess())
})
.catch(err => {
// container state has already been updated, so we are only reverting
// the pinned order here. By default, if this is a discussion that
// that just got moved to the pinned container, we will move it to
// the bottom of the pinned discussions on error
if (discussionCopy.pinned) { originalOrder.push(discussionCopy.id) }
dispatch(actions.dragAndDropFail({
message: I18n.t('Failed to update discussion'),
discussion: discussionCopy,
order: originalOrder,
err,
}))
})
})
.catch(err => {
// reset order and discussion back to original state
dispatch(actions.dragAndDropFail({
message: I18n.t('Failed to update discussion'),
discussion,
order: originalOrder,
err,
}))
})
}
}
actions.searchDiscussions = function searchDiscussions ({ searchTerm, filter }) {
return (dispatch, getState) => {
dispatch(actions.updateDiscussionsSearch({ searchTerm, filter }))

View File

@ -83,3 +83,9 @@ export function saveUserSettings ({currentUserId}, settings) {
export function duplicateDiscussion ({ contextType, contextId }, discussionId) {
return axios.post(`/api/v1/${contextType}s/${contextId}/discussion_topics/${discussionId}/duplicate`)
}
export function reorderPinnedDiscussions ({ contextType, contextId }, order) {
const postData = { order: order.join(',') }
const url = `/api/v1/${contextType}s/${contextId}/discussion_topics/reorder`
return axios.post(url, postData)
}

View File

@ -23,49 +23,31 @@ import I18n from 'i18n!discussions_v2'
import ToggleDetails from '@instructure/ui-core/lib/components/ToggleDetails'
import Text from '@instructure/ui-core/lib/components/Text'
import update from 'immutability-helper'
import DiscussionRow, { DraggableDiscussionRow } from '../../shared/components/DiscussionRow'
import { discussionList } from '../../shared/proptypes/discussion'
import masterCourseDataShape from '../../shared/proptypes/masterCourseData'
import propTypes from '../propTypes'
// We need to look at the previous state of a discussion as well as where it is
// trying to be drag and dropped into in order to create a decent screenreader
// success and fail message
const generateDragAndDropMessages = (props, discussion) => {
if (props.pinned) {
return {
successMessage: I18n.t('Discussion pinned successfully'),
failMessage: I18n.t('Failed to pin discussion'),
}
} else if (discussion.pinned) {
return {
successMessage: I18n.t('Discussion unpinned successfully'),
failMessage: I18n.t('Failed to unpin discussion'),
}
} else if (props.closedState) {
return {
successMessage: I18n.t('Discussion opened for comments successfully'),
failMessage: I18n.t('Failed to close discussion for comments'),
}
} else {
return {
successMessage: I18n.t('Discussion closed for comments successfully'),
failMessage: I18n.t('Failed to open discussion for comments'),
}
}
}
// Handle drag and drop on a discussion. The props passed in tell us how we
// should update the discussion if something is dragged into this container
const discussionTarget = {
drop(props, monitor) {
drop(props, monitor, component) {
const discussion = monitor.getItem()
const updateFields = {}
if (props.closedState !== undefined) updateFields.locked = props.closedState
if (props.pinned !== undefined) updateFields.pinned = props.pinned
const flashMessages = generateDragAndDropMessages(props, discussion)
props.updateDiscussion(discussion, updateFields, flashMessages)
// We currently cannot drag an item from a different container to a specific
// position in the pinned container, thus we only need to set the order when
// rearranging items in the pinned container, not when dragging a locked or
// unpinned discussion to the pinned container.
const order = (props.pinned && discussion.pinned)
? component.state.discussions.map(d => d.id)
: undefined
props.handleDrop(discussion, updateFields, order)
},
}
@ -77,12 +59,13 @@ export default class DiscussionsContainer extends Component {
title: string.isRequired,
toggleSubscribe: func.isRequired,
updateDiscussion: func.isRequired,
handleDrop: func, // eslint-disable-line
duplicateDiscussion: func.isRequired,
cleanDiscussionFocus: func.isRequired,
pinned: bool,
closedState: bool,
closedState: bool, // eslint-disable-line
connectDropTarget: func,
roles: arrayOf(string),
roles: arrayOf(string), // eslint-disable-line
renderContainerBackground: func.isRequired,
onMoveDiscussion: func,
deleteDiscussion: func,
@ -95,14 +78,23 @@ export default class DiscussionsContainer extends Component {
closedState: undefined,
roles: ['user', 'student'],
onMoveDiscussion: null,
deleteDiscussion: null
deleteDiscussion: null,
handleDrop: undefined,
}
componentWillReceiveProps(newProps) {
constructor(props) {
super(props)
this.moveCard = this.moveCard.bind(this)
this.state = {
discussions: props.discussions,
}
}
componentWillReceiveProps(props) {
if((this.props.discussions.length >= 1
&& newProps.discussions.length === 0)
|| (newProps.discussions[0]
&& newProps.discussions[0].focusOn === "toggleButton")) {
&& props.discussions.length === 0)
|| (props.discussions[0]
&& props.discussions[0].focusOn === "toggleButton")) {
if(this.toggleBtn) {
setTimeout(() => {
this.toggleBtn.focus()
@ -110,18 +102,36 @@ export default class DiscussionsContainer extends Component {
});
}
}
if(this.props.discussions !== props.discussions) {
this.setState({
discussions: props.discussions,
})
}
}
wrapperToggleRef = (c) => {
this.toggleBtn = c && c.querySelector('button')
}
renderDiscussions () {
return this.props.discussions.reduce((accumlator, discussion) => {
if (discussion.filtered) {
return accumlator
}
moveCard(dragIndex, hoverIndex) {
const { discussions } = this.state
const dragDiscussion = discussions[dragIndex]
if (!dragDiscussion) {
return
}
const newDiscussions = update(this.state, {
discussions: {
$splice: [[dragIndex, 1], [hoverIndex, 0, dragDiscussion]],
},
})
newDiscussions.discussions = newDiscussions.discussions.map((discussion, index) => ({...discussion, sortableId: index}))
this.setState({discussions: newDiscussions.discussions})
}
renderDiscussions () {
return this.state.discussions.reduce((accumlator, discussion) => {
if (discussion.filtered) { return accumlator }
const row = this.props.permissions.moderate
? <DraggableDiscussionRow
key={discussion.id}
@ -135,6 +145,7 @@ export default class DiscussionsContainer extends Component {
updateDiscussion={this.props.updateDiscussion}
onMoveDiscussion={this.props.onMoveDiscussion}
deleteDiscussion={this.props.deleteDiscussion}
moveCard={this.moveCard}
draggable
/>
: <DiscussionRow

View File

@ -18,7 +18,7 @@
import I18n from 'i18n!discussions_v2'
import React, {Component} from 'react'
import {func, bool, string, arrayOf, number} from 'prop-types'
import {func, bool, string, arrayOf} from 'prop-types'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {DragDropContext} from 'react-dnd'
@ -49,11 +49,15 @@ import {reorderDiscussionsURL} from '../utils'
export default class DiscussionsIndex extends Component {
static propTypes = {
updateDiscussion: func.isRequired,
contextType: string.isRequired,
contextId: number.isRequired,
arrangePinnedDiscussions: func.isRequired,
cleanDiscussionFocus: func.isRequired,
closedForCommentsDiscussions: discussionList,
contextId: string.isRequired,
contextType: string.isRequired,
deleteDiscussion: func.isRequired,
duplicateDiscussion: func.isRequired,
getDiscussions: func.isRequired,
handleDrop: func,
hasLoadedDiscussions: bool.isRequired,
isLoadingDiscussions: bool.isRequired,
masterCourseData: masterCourseDataShape,
@ -62,17 +66,14 @@ export default class DiscussionsIndex extends Component {
roles: arrayOf(string).isRequired,
toggleSubscriptionState: func.isRequired,
unpinnedDiscussions: discussionList,
duplicateDiscussion: func.isRequired,
cleanDiscussionFocus: func.isRequired,
arrangePinnedDiscussions: func.isRequired,
deleteDiscussion: func.isRequired
updateDiscussion: func.isRequired,
}
static defaultProps = {
pinnedDiscussions: [],
unpinnedDiscussions: [],
closedForCommentsDiscussions: [],
masterCourseData: null
handleDrop: undefined,
}
state = {
@ -205,17 +206,18 @@ export default class DiscussionsIndex extends Component {
discussions={this.props.pinnedDiscussions}
permissions={this.props.permissions}
masterCourseData={this.props.masterCourseData}
deleteDiscussion={this.openDeleteDiscussionsModal}
toggleSubscribe={this.props.toggleSubscriptionState}
updateDiscussion={this.props.updateDiscussion}
handleDrop={this.props.handleDrop}
duplicateDiscussion={this.props.duplicateDiscussion}
cleanDiscussionFocus={this.props.cleanDiscussionFocus}
onMoveDiscussion={this.renderMoveDiscussionTray}
deleteDiscussion={this.openDeleteDiscussionsModal}
roles={this.props.roles}
pinned
renderContainerBackground={() =>
pinnedDiscussionBackground({
permissions: this.props.permissions
permissions: this.props.permissions,
})
}
/>
@ -224,13 +226,14 @@ export default class DiscussionsIndex extends Component {
<DroppableDiscussionsContainer
title={I18n.t('Discussions')}
discussions={this.props.unpinnedDiscussions}
deleteDiscussion={this.openDeleteDiscussionsModal}
permissions={this.props.permissions}
masterCourseData={this.props.masterCourseData}
toggleSubscribe={this.props.toggleSubscriptionState}
updateDiscussion={this.props.updateDiscussion}
handleDrop={this.props.handleDrop}
duplicateDiscussion={this.props.duplicateDiscussion}
cleanDiscussionFocus={this.props.cleanDiscussionFocus}
deleteDiscussion={this.openDeleteDiscussionsModal}
pinned={false}
closedState={false}
roles={this.props.roles}
@ -248,12 +251,13 @@ export default class DiscussionsIndex extends Component {
title={I18n.t('Closed for Comments')}
discussions={this.props.closedForCommentsDiscussions}
permissions={this.props.permissions}
deleteDiscussion={this.openDeleteDiscussionsModal}
masterCourseData={this.props.masterCourseData}
toggleSubscribe={this.props.toggleSubscriptionState}
updateDiscussion={this.props.updateDiscussion}
handleDrop={this.props.handleDrop}
duplicateDiscussion={this.props.duplicateDiscussion}
cleanDiscussionFocus={this.props.cleanDiscussionFocus}
deleteDiscussion={this.openDeleteDiscussionsModal}
roles={this.props.roles}
pinned={false}
closedState
@ -269,8 +273,7 @@ export default class DiscussionsIndex extends Component {
defaultOpen
selectedCount={1}
applicationElement={() => document.getElementById('application')}
/>)}
</Container>
/>)} </Container>
)
}
@ -313,6 +316,7 @@ const connectActions = dispatch =>
'getDiscussions',
'toggleSubscriptionState',
'updateDiscussion',
'handleDrop',
'duplicateDiscussion',
'cleanDiscussionFocus',
'arrangePinnedDiscussions',

View File

@ -56,6 +56,12 @@ const reducerMap = {
[actionTypes.UPDATE_DISCUSSION_FAIL]: (state, action) => (
copyAndUpdateDiscussionState(state, action.payload.discussion)
),
[actionTypes.DRAG_AND_DROP_START]: (state, action) => (
copyAndUpdateDiscussionState(state, action.payload.discussion)
),
[actionTypes.DRAG_AND_DROP_FAIL]: (state, action) => (
copyAndUpdateDiscussionState(state, action.payload.discussion)
),
}
Object.assign(reducerMap, subscriptionReducerMap, duplicationReducerMap,

View File

@ -16,6 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { actionTypes } from '../actions'
import { setSortableId } from '../utils'
const deleteReducerMap = {
[actionTypes.DELETE_DISCUSSION_SUCCESS]: (state, action) => {
@ -24,20 +25,20 @@ const deleteReducerMap = {
))
const newState= state.slice()
if (oldIndex < 0) {
return newState;
return newState
}
if(oldIndex === 0) {
newState.splice(oldIndex, 1)
if(newState.length) {
newState[0] = { ...newState[0], focusOn: "toggleButton" }
}
return newState;
return setSortableId(newState)
} else {
const newFocusIndex = oldIndex - 1;
if(newState[newFocusIndex]) {
newState[newFocusIndex] = { ...newState[newFocusIndex], focusOn: "manageMenu" }
newState.splice(oldIndex, 1)
return newState
return setSortableId(newState)
} else { // There is no discussions left
return []
}

View File

@ -16,6 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { actionTypes } from '../actions'
import { setSortableId } from '../utils'
function updatePositions(discussions, updatedPositions) {
return discussions.map( (discussion) => {
@ -47,7 +48,8 @@ const duplicationReducerMap = {
: remainingOriginalDiscussions
delete newDiscussion.new_positions
return newStateBeginning.concat(newStateEnd)
const unsortedDiscussions = newStateBeginning.concat(newStateEnd)
return setSortableId(unsortedDiscussions)
} else {
// The original discussion wasn't in this container, so the state should
// not be changed.

View File

@ -24,6 +24,7 @@ import duplicationReducerMap from './duplicationReducerMap'
import deleteReducerMap from './deleteReducerMap'
import cleanDiscussionFocusReducerMap from './cleanDiscussionFocusReducerMap'
import searchReducerMap from './searchReducerMap'
import { setSortableId } from '../utils'
// We need to not change the ordering of the diccussions here, ie we can't
// use the same .sort that we use in the GET_DISCUSSIONS_SUCCESS. This is
@ -44,20 +45,14 @@ function copyAndUpdateDiscussionState(oldState, updatedDiscussion) {
return newState
}
function orderPinnedDiscussions(state, order) {
const discussions = order ? order.map(id => state.find(discussion => discussion.id === id)) : state
return setSortableId(discussions)
}
const reducerMap = {
[actionTypes.ARRANGE_PINNED_DISCUSSIONS]: (state, action) => {
if (!action.payload.order) {
return state
}
const oldDiscussionList = state.slice()
const newPinnedDiscussions = action.payload.order.map(id => {
const currentDiscussion = oldDiscussionList.find(
discussion => discussion.id === id
)
return currentDiscussion
})
return newPinnedDiscussions
},
[actionTypes.ARRANGE_PINNED_DISCUSSIONS]: (state, action) =>
orderPinnedDiscussions(state, action.payload.order),
[actionTypes.GET_DISCUSSIONS_SUCCESS]: (state, action) => {
const discussions = action.payload.data || []
const pinnedDiscussions = discussions.reduce((accumlator, discussion) => {
@ -66,12 +61,20 @@ const reducerMap = {
}
return accumlator
}, [])
return orderBy(pinnedDiscussions, ['position'], ['asc'])
return setSortableId(orderBy(pinnedDiscussions, ['position'], ['asc']))
},
[actionTypes.UPDATE_DISCUSSION_START]: (state, action) =>
copyAndUpdateDiscussionState(state, action.payload.discussion),
[actionTypes.UPDATE_DISCUSSION_FAIL]: (state, action) =>
copyAndUpdateDiscussionState(state, action.payload.discussion)
copyAndUpdateDiscussionState(state, action.payload.discussion),
[actionTypes.DRAG_AND_DROP_START]: (state, action) => {
const updatedState = copyAndUpdateDiscussionState(state, action.payload.discussion)
return orderPinnedDiscussions(updatedState, action.payload.order)
},
[actionTypes.DRAG_AND_DROP_FAIL]: (state, action) => {
const updatedState = copyAndUpdateDiscussionState(state, action.payload.discussion)
return orderPinnedDiscussions(updatedState, action.payload.order)
}
}
Object.assign(reducerMap, subscriptionReducerMap, duplicationReducerMap, cleanDiscussionFocusReducerMap, searchReducerMap, deleteReducerMap)

View File

@ -56,6 +56,12 @@ const reducerMap = {
[actionTypes.UPDATE_DISCUSSION_FAIL]: (state, action) => (
copyAndUpdateDiscussionState(state, action.payload.discussion)
),
[actionTypes.DRAG_AND_DROP_START]: (state, action) => (
copyAndUpdateDiscussionState(state, action.payload.discussion)
),
[actionTypes.DRAG_AND_DROP_FAIL]: (state, action) => (
copyAndUpdateDiscussionState(state, action.payload.discussion)
),
}
Object.assign(reducerMap, subscriptionReducerMap, duplicationReducerMap,

View File

@ -19,3 +19,14 @@
export function reorderDiscussionsURL ({ contextType, contextId }) {
return `/api/v1/${contextType}s/${contextId}/discussion_topics/reorder`
}
export function setSortableId(discussions) {
let pinnedDiscussion = false
for( let i = 0; i < discussions.length; i++) {
if(discussions[i].pinned) {
pinnedDiscussion = true
break;
}
}
return !pinnedDiscussion ? discussions : discussions.map((discussion, index) => ({...discussion, sortableId: index}))
}

View File

@ -39,7 +39,7 @@ import masterCourseDataShape from '../proptypes/masterCourseData'
export default class CourseItemRow extends Component {
static propTypes = {
actionsContent: node,
actionsContent: arrayOf(node),
metaContent: node,
masterCourse: shape({
courseData: masterCourseDataShape,
@ -48,7 +48,9 @@ export default class CourseItemRow extends Component {
author: authorShape,
title: string.isRequired,
body: node,
isDragging: bool,
connectDragSource: func,
connectDropTarget: func,
id: string,
className: string,
itemUrl: string,
@ -82,6 +84,7 @@ export default class CourseItemRow extends Component {
},
id: null,
className: '',
isDragging: false,
itemUrl: null,
selectable: false,
draggable: false,
@ -89,7 +92,8 @@ export default class CourseItemRow extends Component {
isRead: true,
icon: null,
showAvatar: false,
connectDragSource: null,
connectDragSource: (component) => component,
connectDropTarget: (component) => component,
onSelectedChanged () {},
showManageMenu: false,
manageMenuOptions: [],
@ -105,10 +109,6 @@ export default class CourseItemRow extends Component {
isSelected: this.props.defaultSelected,
}
componentWillUnmount () {
this.unmountMasterCourseLock()
}
componentDidMount () {
this.onFocusManage(this.props)
}
@ -133,6 +133,10 @@ export default class CourseItemRow extends Component {
}
}
componentWillUnmount () {
this.unmountMasterCourseLock()
}
onSelectChanged = (e) => {
this.setState({ isSelected: e.target.checked }, () => {
this.props.onSelectedChanged({ selected: this.state.isSelected, id: this.props.id })
@ -178,15 +182,14 @@ export default class CourseItemRow extends Component {
render () {
const classes = cx('ic-item-row')
return (
<div className={`${classes} ${this.props.className}`}>
this.props.connectDropTarget(this.props.connectDragSource(
<div style={{ opacity: (this.props.isDragging) ? 0 : 1 }} className={`${classes} ${this.props.className}`}>
{(this.props.draggable && this.props.connectDragSource && <div className="ic-item-row__drag-col">
{this.props.connectDragSource(
<span>
<Text color="secondary" size="large">
<IconDragHandleLine />
</Text>
</span>, {dropEffect: 'copy'})
}
<span>
<Text color="secondary" size="large">
<IconDragHandleLine />
</Text>
</span>
</div>)}
{
!this.props.isRead ? (
@ -242,7 +245,7 @@ export default class CourseItemRow extends Component {
{this.props.metaContent}
</div>
</div>
</div>
</div>, {dropEffect: 'copy'}))
)
}
}

View File

@ -17,12 +17,13 @@
*/
import I18n from 'i18n!discussion_row'
import React from 'react'
import React, { Component } from 'react'
import { func, bool } from 'prop-types'
import $ from 'jquery'
import 'jquery.instructure_date_and_time'
import { DragSource } from 'react-dnd';
import { DragSource, DropTarget } from 'react-dnd';
import { findDOMNode } from 'react-dom'
import Container from '@instructure/ui-core/lib/components/Container'
import Badge from '@instructure/ui-core/lib/components/Badge'
import Text from '@instructure/ui-core/lib/components/Text'
@ -43,9 +44,11 @@ import IconPinLine from 'instructure-icons/lib/Line/IconPinLine'
import IconReply from 'instructure-icons/lib/Line/IconReplyLine'
import DiscussionModel from 'compiled/models/DiscussionTopic'
import compose from '../helpers/compose'
import SectionsTooltip from '../SectionsTooltip'
import CourseItemRow from './CourseItemRow'
import UnreadBadge from './UnreadBadge'
import ToggleIcon from './ToggleIcon'
import discussionShape from '../proptypes/discussion'
import masterCourseDataShape from '../proptypes/masterCourseData'
@ -71,80 +74,144 @@ const discussionTarget = {
},
}
export default function DiscussionRow ({ discussion, masterCourseData, rowRef, onSelectedChanged,
connectDragSource, connectDragPreview, draggable,
onToggleSubscribe, updateDiscussion, canManage, canPublish,
duplicateDiscussion, cleanDiscussionFocus, onMoveDiscussion,
deleteDiscussion }) {
const makePinSuccessFailMessages = () => {
const successMessage = discussion.pinned ?
I18n.t('Unpin of discussion %{title} succeeded', { title: discussion.title }) :
I18n.t('Pin of discussion %{title} succeeded', { title: discussion.title })
const failMessage = discussion.pinned ?
I18n.t('Unpin of discussion %{title} failed', { title: discussion.title }) :
I18n.t('Pin of discussion %{title} failed', { title: discussion.title })
return { successMessage, failMessage }
const otherTarget = {
hover(props, monitor, component) {
const dragIndex = monitor.getItem().sortableId
const hoverIndex = props.discussion.sortableId
if (dragIndex === undefined || hoverIndex === undefined) {
return
}
if (dragIndex === hoverIndex) {
return
}
const hoverBoundingRect = findDOMNode(component).getBoundingClientRect() // eslint-disable-line
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2
const clientOffset = monitor.getClientOffset()
const hoverClientY = clientOffset.y - hoverBoundingRect.top
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return
}
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return
}
props.moveCard(dragIndex, hoverIndex)
monitor.getItem().sortableId = hoverIndex // eslint-disable-line
},
}
export default class DiscussionRow extends Component {
static propTypes = {
discussion: discussionShape.isRequired,
masterCourseData: masterCourseDataShape,
rowRef: func,
moveCard: func, // eslint-disable-line
deleteDiscussion: func.isRequired,
onSelectedChanged: func,
connectDragSource: func,
connectDragPreview: func,
connectDropTarget: func,
isDragging: bool,
draggable: bool,
onToggleSubscribe: func.isRequired,
canManage: bool.isRequired,
duplicateDiscussion: func.isRequired,
cleanDiscussionFocus: func.isRequired,
updateDiscussion: func.isRequired,
canPublish: bool.isRequired,
onMoveDiscussion: func,
}
const onManageDiscussion = (e, { action, id }) => {
static defaultProps = {
connectDragPreview (component) {return component},
connectDragSource (component) {return component},
connectDropTarget (component) {return component},
draggable: false,
isDragging: false,
masterCourseData: null,
moveCard: () => {},
onMoveDiscussion: null,
onSelectedChanged () {},
rowRef () {},
}
onManageDiscussion = (e, { action, id }) => {
switch (action) {
case 'duplicate':
duplicateDiscussion(id)
this.props.duplicateDiscussion(id)
break
case 'moveTo':
onMoveDiscussion({ id, title: discussion.title })
this.props.onMoveDiscussion({ id, title: this.props.discussion.title })
break
case 'togglepinned':
updateDiscussion(discussion, { pinned: !discussion.pinned },
makePinSuccessFailMessages(discussion), 'manageMenu')
this.props.updateDiscussion(this.props.discussion, { pinned: !this.props.discussion.pinned },
this.makePinSuccessFailMessages(this.props.discussion), 'manageMenu')
break
case 'delete':
deleteDiscussion(discussion)
this.props.deleteDiscussion(this.props.discussion)
break
case 'togglelocked':
updateDiscussion(discussion, { locked: !discussion.locked },
makePinSuccessFailMessages(discussion), 'manageMenu')
this.props.updateDiscussion(this.props.discussion, { locked: !this.props.discussion.locked },
this.makePinSuccessFailMessages(this.props.discussion), 'manageMenu')
break
default:
throw new Error(I18n.t('Unknown manage discussion action encountered'))
}
}
const timestamp = makeTimestamp(discussion)
const readCount = discussion.discussion_subentry_count > 0
makePinSuccessFailMessages = () => {
const successMessage = this.props.discussion.pinned ?
I18n.t('Unpin of discussion %{title} succeeded', { title: this.props.discussion.title }) :
I18n.t('Pin of discussion %{title} succeeded', { title: this.props.discussion.title })
const failMessage = this.props.discussion.pinned ?
I18n.t('Unpin of discussion %{title} failed', { title: this.props.discussion.title }) :
I18n.t('Pin of discussion %{title} failed', { title: this.props.discussion.title })
return { successMessage, failMessage }
}
readCount = () => {
const readCount = this.props.discussion.discussion_subentry_count > 0
? (
<UnreadBadge
unreadCount={discussion.unread_count}
unreadLabel={I18n.t('%{count} unread replies', { count: discussion.unread_count })}
totalCount={discussion.discussion_subentry_count}
totalLabel={I18n.t('%{count} replies', { count: discussion.discussion_subentry_count })}
unreadCount={this.props.discussion.unread_count}
unreadLabel={I18n.t('%{count} unread replies', { count: this.props.discussion.unread_count })}
totalCount={this.props.discussion.discussion_subentry_count}
totalLabel={I18n.t('%{count} replies', { count: this.props.discussion.discussion_subentry_count })}
/>
)
: null
const subscribeButton = (
return readCount
}
subscribeButton = () => (
<ToggleIcon
toggled={discussion.subscribed}
OnIcon={<IconRssSolid title={I18n.t('Unsubscribe from %{title}', { title: discussion.title })} />}
OffIcon={<IconRssLine title={I18n.t('Subscribe to %{title}', { title: discussion.title })} />}
onToggleOn={() => onToggleSubscribe(discussion)}
onToggleOff={() => onToggleSubscribe(discussion)}
disabled={discussion.subscription_hold !== undefined}
toggled={this.props.discussion.subscribed}
OnIcon={<IconRssSolid title={I18n.t('Unsubscribe from %{title}', { title: this.props.discussion.title })} />}
OffIcon={<IconRssLine title={I18n.t('Subscribe to %{title}', { title: this.props.discussion.title })} />}
onToggleOn={() => this.props.onToggleSubscribe(this.props.discussion)}
onToggleOff={() => this.props.onToggleSubscribe(this.props.discussion)}
disabled={this.props.discussion.subscription_hold !== undefined}
className="subscribe-button"
/>
)
const publishButton = canPublish
publishButton = () => (
this.props.canPublish
? (<ToggleIcon
toggled={discussion.published}
OnIcon={<IconPublishSolid title={I18n.t('Publish %{title}', { title: discussion.title })} />}
OffIcon={<IconPublishLine title={I18n.t('Unpublish %{title}', { title: discussion.title })} />}
onToggleOn={() => updateDiscussion(discussion, {published: true}, {})}
onToggleOff={() => updateDiscussion(discussion, {published: false}, {})}
toggled={this.props.discussion.published}
OnIcon={<IconPublishSolid title={I18n.t('Publish %{title}', { title: this.props.discussion.title })} />}
OffIcon={<IconPublishLine title={I18n.t('Unpublish %{title}', { title: this.props.discussion.title })} />}
onToggleOn={() => this.props.updateDiscussion(this.props.discussion, {published: true}, {})}
onToggleOff={() => this.props.updateDiscussion(this.props.discussion, {published: false}, {})}
className="publish-button"
/>)
: null
)
const pinMenuItemDisplay = () =>{
if (discussion.pinned) {
pinMenuItemDisplay = () => {
if (this.props.discussion.pinned) {
return (
<span aria-hidden='true'>
<IconPinLine />&nbsp;&nbsp;{I18n.t('Unpin')}
@ -159,173 +226,163 @@ export default function DiscussionRow ({ discussion, masterCourseData, rowRef, o
}
}
const menuList = [
<MenuItem
key="duplicate"
value={{ action: 'duplicate', id: discussion.id }}
id="duplicate-discussion-menu-option"
>
<span aria-hidden='true'>
<IconCopySolid />&nbsp;&nbsp;{I18n.t('Duplicate')}
</span>
<ScreenReaderContent> { I18n.t('Duplicate discussion %{title}', { title: discussion.title }) } </ScreenReaderContent>
</MenuItem>,
<MenuItem
key="togglepinned"
value={{ action: 'togglepinned', id: discussion.id }}
id="togglepinned-discussion-menu-option"
>
{pinMenuItemDisplay()}
<ScreenReaderContent>
{ discussion.pinned
? I18n.t('Unpin discussion %{title}', { title: discussion.title })
: I18n.t('Pin discussion %{title}', { title: discussion.title })}
</ScreenReaderContent>
</MenuItem>,
<MenuItem
key="delete"
value={{ action: 'delete', id: discussion.id }}
id="delete-discussion-menu-option"
>
<span aria-hidden='true'>
<IconTrashSolid />&nbsp;&nbsp;{I18n.t('Delete')}
</span>
<ScreenReaderContent> { I18n.t('Delete discussion %{title}', { title: discussion.title }) } </ScreenReaderContent>
</MenuItem>,
<MenuItem
key="togglelocked"
value={{ action: 'togglelocked', id: discussion.id }}
id="togglelocked-discussion-menu-option"
>
<span aria-hidden='true'>
<IconReply />&nbsp;&nbsp;{ discussion.locked
? I18n.t('Open for comments')
: I18n.t('Close for comments') }
</span>
<ScreenReaderContent>
{ discussion.locked
? I18n.t('Open discussion %{title} for comments', { title: discussion.title })
: I18n.t('Close discussion %{title} for comments', { title: discussion.title })}
</ScreenReaderContent>
</MenuItem>,
]
if(onMoveDiscussion) {
menuList.push(
renderMenuList = () => {
const menuList = [
<MenuItem
key="move"
value={{ action: 'moveTo', id: discussion.id, title: discussion.title }}
id="move-discussion-menu-option"
key="duplicate"
value={{ action: 'duplicate', id: this.props.discussion.id }}
id="duplicate-discussion-menu-option"
>
<span aria-hidden='true'>
<IconUpdownLine />&nbsp;&nbsp;{I18n.t('Move To')}
<IconCopySolid />&nbsp;&nbsp;{I18n.t('Duplicate')}
</span>
<ScreenReaderContent> { I18n.t('Move discussion %{title}', { title: discussion.title }) } </ScreenReaderContent>
</MenuItem>
)
<ScreenReaderContent> { I18n.t('Duplicate discussion %{title}', { title: this.props.discussion.title }) } </ScreenReaderContent>
</MenuItem>,
<MenuItem
key="togglepinned"
value={{ action: 'togglepinned', id: this.props.discussion.id }}
id="togglepinned-discussion-menu-option"
>
{this.pinMenuItemDisplay()}
<ScreenReaderContent>
{ this.props.discussion.pinned
? I18n.t('Unpin discussion %{title}', { title: this.props.discussion.title })
: I18n.t('Pin discussion %{title}', { title: this.props.discussion.title })}
</ScreenReaderContent>
</MenuItem>,
<MenuItem
key="delete"
value={{ action: 'delete', id: this.props.discussion.id }}
id="delete-discussion-menu-option"
>
<span aria-hidden='true'>
<IconTrashSolid />&nbsp;&nbsp;{I18n.t('Delete')}
</span>
<ScreenReaderContent> { I18n.t('Delete discussion %{title}', { title: this.props.discussion.title }) } </ScreenReaderContent>
</MenuItem>,
<MenuItem
key="togglelocked"
value={{ action: 'togglelocked', id: this.props.discussion.id }}
id="togglelocked-discussion-menu-option"
>
<span aria-hidden='true'>
<IconReply />&nbsp;&nbsp;{ this.props.discussion.locked
? I18n.t('Open for comments')
: I18n.t('Close for comments') }
</span>
<ScreenReaderContent>
{ this.props.discussion.locked
? I18n.t('Open discussion %{title} for comments', { title: this.props.discussion.title })
: I18n.t('Close discussion %{title} for comments', { title: this.props.discussion.title })}
</ScreenReaderContent>
</MenuItem>,
]
if (this.props.onMoveDiscussion) {
menuList.push(
<MenuItem
key="move"
value={{ action: 'moveTo', id: this.props.discussion.id, title: this.props.discussion.title }}
id="move-discussion-menu-option"
>
<span aria-hidden='true'>
<IconUpdownLine />&nbsp;&nbsp;{I18n.t('Move To')}
</span>
<ScreenReaderContent> { I18n.t('Move discussion %{title}', { title: this.props.discussion.title }) } </ScreenReaderContent>
</MenuItem>
)
}
return menuList
}
// necessary because discussions return html from RCE
const contentWrapper = document.createElement('span')
contentWrapper.innerHTML = discussion.message
const textContent = contentWrapper.textContent.trim()
return connectDragPreview (
<div>
<Grid startAt="medium" vAlign="middle" colSpacing="none">
<GridRow>
render () {
// necessary because discussions return html from RCE
const contentWrapper = document.createElement('span')
contentWrapper.innerHTML = this.props.discussion.message
const textContent = contentWrapper.textContent.trim()
return this.props.connectDragPreview (
<div>
<Grid startAt="medium" vAlign="middle" colSpacing="none">
<GridRow>
{/* discussion topics is different for badges so we use our own read indicator instead of passing to isRead */}
<GridCol width="auto">
{!(discussion.read_state === "read")
? <Badge margin="0 small x-small 0" standalone type="notification" />
: <Container display="block" margin="0 small x-small 0">
<GridCol width="auto">
{!(this.props.discussion.read_state === "read")
? <Badge margin="0 small x-small 0" standalone type="notification" />
: <Container display="block" margin="0 small x-small 0">
<Container display="block" margin="0 small x-small 0" />
</Container>}
</GridCol>
<GridCol>
<CourseItemRow
ref={rowRef}
className="ic-discussion-row"
key={discussion.id}
id={discussion.id}
focusOn={discussion.focusOn}
draggable={draggable}
connectDragSource={connectDragSource}
icon={
<Text color={draggable ? "success" : "secondary"} size="large">
<IconAssignmentLine />
</Text>
}
isRead
author={discussion.author}
title={discussion.title}
body={textContent ? <div className="ic-discussion-row__content">{textContent}</div> : null}
sectionToolTip={
<SectionsTooltip
totalUserCount={discussion.user_count}
sections={discussion.sections}
/>
}
itemUrl={discussion.html_url}
onSelectedChanged={onSelectedChanged}
showManageMenu={canManage}
onManageMenuSelect={onManageDiscussion}
clearFocusDirectives={cleanDiscussionFocus}
manageMenuOptions={menuList}
masterCourse={{
courseData: masterCourseData || {},
getLockOptions: () => ({
model: new DiscussionModel(discussion),
unlockedText: I18n.t('%{title} is unlocked. Click to lock.', {title: discussion.title}),
lockedText: I18n.t('%{title} is locked. Click to unlock', {title: discussion.title}),
course_id: masterCourseData.masterCourse.id,
content_id: discussion.id,
content_type: 'discussion_topic',
}),
}}
metaContent={
<div>
<span className="ic-item-row__meta-content-heading">
<Text size="small" as="p">{timestamp.title}</Text>
</span>
<Text color="secondary" size="small" as="p">{$.datetimeString(timestamp.date, {format: 'medium'})}</Text>
</div>
}
actionsContent={[readCount, subscribeButton, publishButton]}
/>
</GridCol>
</GridRow>
</Grid>
</div>
)
</Container>}
</GridCol>
<GridCol>
<CourseItemRow
ref={this.props.rowRef}
className="ic-discussion-row"
key={this.props.discussion.id}
id={this.props.discussion.id}
isDragging={this.props.isDragging}
focusOn={this.props.discussion.focusOn}
draggable={this.props.draggable}
connectDragSource={this.props.connectDragSource}
connectDropTarget={this.props.connectDropTarget}
icon={
<Text color={this.props.draggable ? "success" : "secondary"} size="large">
<IconAssignmentLine />
</Text>
}
isRead
author={this.props.discussion.author}
title={this.props.discussion.title}
body={textContent ? <div className="ic-discussion-row__content">{textContent}</div> : null}
sectionToolTip={
<SectionsTooltip
totalUserCount={this.props.discussion.user_count}
sections={this.props.discussion.sections}
/>
}
itemUrl={this.props.discussion.html_url}
onSelectedChanged={this.props.onSelectedChanged}
showManageMenu={this.props.canManage}
onManageMenuSelect={this.onManageDiscussion}
clearFocusDirectives={this.props.cleanDiscussionFocus}
manageMenuOptions={this.renderMenuList()}
masterCourse={{
courseData: this.props.masterCourseData || {},
getLockOptions: () => ({
model: new DiscussionModel(this.props.discussion),
unlockedText: I18n.t('%{title} is unlocked. Click to lock.', {title: this.props.discussion.title}),
lockedText: I18n.t('%{title} is locked. Click to unlock', {title: this.props.discussion.title}),
course_id: this.props.masterCourseData.masterCourse.id,
content_id: this.props.discussion.id,
content_type: 'discussion_topic',
}),
}}
metaContent={
<div>
<span className="ic-item-row__meta-content-heading">
<Text size="small" as="p">{makeTimestamp(this.props.discussion).title}</Text>
</span>
<Text color="secondary" size="small" as="p">
{$.datetimeString(makeTimestamp(this.props.discussion).date, {format: 'medium'})}
</Text>
</div>
}
actionsContent={[this.readCount(), this.subscribeButton(), this.publishButton()]}
/>
</GridCol>
</GridRow>
</Grid>
</div>
)
}
}
DiscussionRow.propTypes = {
discussion: discussionShape.isRequired,
masterCourseData: masterCourseDataShape,
rowRef: func,
onSelectedChanged: func,
connectDragSource: func,
connectDragPreview: func,
draggable: bool,
onToggleSubscribe: func.isRequired,
canManage: bool.isRequired,
onManageSubscription: func.isRequired,
duplicateDiscussion: func.isRequired,
cleanDiscussionFocus: func.isRequired,
updateDiscussion: func.isRequired,
canPublish: bool.isRequired,
deleteDiscussion: func.isRequired,
}
DiscussionRow.defaultProps = {
connectDragSource (component) {return component},
masterCourseData: null,
rowRef () {},
onSelectedChanged () {},
connectDragPreview (component) {return component},
}
export const DraggableDiscussionRow = DragSource('Discussion', discussionTarget, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging(),
connectDragPreview: connect.dragPreview(),
}))(DiscussionRow)
/* eslint-disable new-cap */
export const DraggableDiscussionRow = compose(
DropTarget('Discussion', otherTarget, connect => ({
connectDropTarget: connect.dropTarget()
})),
DragSource('Discussion', discussionTarget, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging(),
connectDragPreview: connect.dragPreview(),
}))
)(DiscussionRow)

View File

@ -23,7 +23,7 @@ const discussion = shape({
position: number,
published: bool.isRequired,
title: string.isRequired,
message: string.isRequired,
message: string,
posted_at: string.isRequired,
author: author.isRequired,
read_state: oneOf(['read', 'unread']).isRequired,

View File

@ -67,7 +67,7 @@ test('renders a draggable discussion row when user has moderate permissions', ()
const props = defaultProps()
props.permissions.moderate = true
const tree = shallow(<DiscussionContainer {...props} />)
const node = tree.find('DragSource(DiscussionRow)')
const node = tree.find('DropTarget(DragSource(DiscussionRow))')
ok(node.exists())
})

View File

@ -19,13 +19,17 @@
import actions from 'jsx/discussions/actions'
import * as apiClient from 'jsx/discussions/apiClient'
import $ from 'jquery';
import 'compiled/jquery.rails_flash_notifications'
import 'compiled/jquery.rails_flash_notifications' // eslint-disable-line
let sandbox = null
function getState() {
return ([{ id: 1 }, { id: 2, shouldGetFocus: true }] )
}
let sandbox = []
const mockApiClient = (method, res) => {
sandbox = sinon.sandbox.create()
sandbox.stub(apiClient, method).returns(res)
sandbox.push(sinon.sandbox.create())
sandbox[sandbox.length - 1].stub(apiClient, method).returns(res)
}
const mockSuccess = (method, data = {}) => mockApiClient(method, Promise.resolve(data))
@ -33,12 +37,13 @@ const mockFail = (method, err = new Error('Request Failed')) => mockApiClient(me
QUnit.module('Discussions redux actions', {
teardown () {
if (sandbox) sandbox.restore()
sandbox = null
sandbox.forEach(mock => mock.restore())
sandbox = []
}
})
test('updateDiscussion dispatches UPDATE_DISCUSSION_START', () => {
mockSuccess('updateDiscussion', {})
const state = { discussions: { pages: { 1: { items: [] } }, currentPage: 1 } }
const discussion = { pinned: false, locked: false }
const updateFields = { pinned: true }
@ -170,93 +175,249 @@ test('updateDiscussion throws exception if updating a field that does not exist
)
})
QUnit.module('Discussion actions', {
setup () {
this.dispatchSpy = sinon.spy()
this.getState = () => ([{ id: 1 }, { id: 2, shouldGetFocus: true }] )
},
teardown () {
if (sandbox) sandbox.restore()
sandbox = null
test('handleDrop dispatches DRAG_AND_DROP_START', (assert) => {
const done = assert.async()
mockSuccess('updateDiscussion', {})
mockSuccess('reorderPinnedDiscussions', {})
const dispatchSpy = sinon.spy()
const state = {
pinnedDiscussions: [{id: 2, pinned: true}],
unpinnedDiscussions: [{id: 1, pinned: false}],
closedForCommentsDiscussions: [],
}
actions.handleDrop({id: 1, pinned: false}, {pinned: true}, [1, 2])(dispatchSpy, () => state)
setTimeout(() => {
const expected = [
{
payload: {
discussion: {id: 1, pinned: true},
order: [1, 2]
},
type: "DRAG_AND_DROP_START"
}
]
deepEqual(dispatchSpy.firstCall.args, expected)
done()
})
})
test('does not call the API if the discussion has a subscription_hold', function() {
test('handleDrop dispatches DRAG_AND_DROP_SUCCESS if no api calls fail', (assert) => {
const done = assert.async()
const dispatchSpy = sinon.spy()
const state = {
pinnedDiscussions: [{id: 2, pinned: true}],
unpinnedDiscussions: [{id: 1, pinned: false}],
closedForCommentsDiscussions: [],
}
mockSuccess('updateDiscussion', {})
mockSuccess('reorderPinnedDiscussions', {})
actions.handleDrop({id: 1, pinned: false}, {pinned: true}, [1, 2])(dispatchSpy, () => state)
setTimeout(() => {
deepEqual(dispatchSpy.secondCall.args, [{type: "DRAG_AND_DROP_SUCCESS" }])
done()
})
})
test('handleDrop dispatches DRAG_AND_DROP_FAIL if updateDiscussion api call fails', (assert) => {
const done = assert.async()
const dispatchSpy = sinon.spy()
const state = {
pinnedDiscussions: [{id: 2, pinned: true}],
unpinnedDiscussions: [{id: 1, pinned: false}],
closedForCommentsDiscussions: [],
}
mockFail('updateDiscussion', {})
actions.handleDrop({id: 1, pinned: false}, {pinned: true}, [1, 2])(dispatchSpy, () => state)
setTimeout(() => {
const expected = [{
payload: {
order: [2],
discussion: {
id: 1,
pinned: false
},
err: {},
message: "Failed to update discussion",
},
type: 'DRAG_AND_DROP_FAIL'
}]
deepEqual(dispatchSpy.secondCall.args, expected)
done()
})
})
test('handleDrop dispatches DRAG_AND_DROP_FAIL if reorderPinnedDiscussions api call fails', (assert) => {
const done = assert.async()
const dispatchSpy = sinon.spy()
const state = {
pinnedDiscussions: [{id: 2, pinned: true}],
unpinnedDiscussions: [{id: 1, pinned: false}],
closedForCommentsDiscussions: [],
}
mockSuccess('updateDiscussion', {})
mockFail('reorderPinnedDiscussions', {})
actions.handleDrop({id: 1, pinned: false}, {pinned: true}, [1, 2])(dispatchSpy, () => state)
setTimeout(() => {
const expected = [{
payload: {
order: [2, 1],
discussion: {
id: 1,
pinned: true
},
err: {},
message: "Failed to update discussion",
},
type: 'DRAG_AND_DROP_FAIL'
}]
deepEqual(dispatchSpy.secondCall.args, expected)
done()
})
})
test('handleDrop calls reorderPinnedDiscussions if pinned and order present', (assert) => {
const done = assert.async()
const dispatchSpy = sinon.spy()
const state = {
pinnedDiscussions: [{id: 2, pinned: true}],
unpinnedDiscussions: [{id: 1, pinned: false}],
closedForCommentsDiscussions: [],
}
mockSuccess('updateDiscussion', {})
mockSuccess('reorderPinnedDiscussions', {})
actions.handleDrop({id: 1, pinned: false}, {pinned: true}, [1, 2])(dispatchSpy, () => state)
setTimeout(() => {
deepEqual(apiClient.reorderPinnedDiscussions.firstCall.args[1], [1, 2])
done()
})
})
test('handleDrop does not call reorderPinnedDiscussions if discussion is not pinned', (assert) => {
const done = assert.async()
const dispatchSpy = sinon.spy()
const state = {
pinnedDiscussions: [{id: 2, pinned: true}],
unpinnedDiscussions: [{id: 1, pinned: false}],
closedForCommentsDiscussions: [],
}
mockSuccess('updateDiscussion', {})
mockSuccess('reorderPinnedDiscussions', {})
actions.handleDrop({id: 1, pinned: false}, {pinned: false}, [1, 2])(dispatchSpy, () => state)
setTimeout(() => {
deepEqual(apiClient.reorderPinnedDiscussions.called, false)
done()
})
})
test('handleDrop does not call reorderPinnedDiscussions if ordering not present', (assert) => {
const done = assert.async()
const dispatchSpy = sinon.spy()
const state = {
pinnedDiscussions: [{id: 2, pinned: true}],
unpinnedDiscussions: [{id: 1, pinned: false}],
closedForCommentsDiscussions: [],
}
mockSuccess('updateDiscussion', {})
mockSuccess('reorderPinnedDiscussions', {})
actions.handleDrop({id: 1, pinned: false}, {pinned: true})(dispatchSpy, () => state)
setTimeout(() => {
deepEqual(apiClient.reorderPinnedDiscussions.called, false)
done()
})
})
test('does not call the API if the discussion has a subscription_hold', () => {
const dispatchSpy = sinon.spy()
const discussion = { subscription_hold: 'test hold' }
actions.toggleSubscriptionState(discussion)(this.dispatchSpy, this.getState)
equal(this.dispatchSpy.callCount, 0)
actions.toggleSubscriptionState(discussion)(dispatchSpy, getState)
equal(dispatchSpy.callCount, 0)
})
test('calls unsubscribeFromTopic if the discussion is currently subscribed', function() {
test('calls unsubscribeFromTopic if the discussion is currently subscribed', () => {
const dispatchSpy = sinon.spy()
const discussion = { id: 1, subscribed: true }
mockSuccess('unsubscribeFromTopic', {})
actions.toggleSubscriptionState(discussion)(this.dispatchSpy, this.getState)
actions.toggleSubscriptionState(discussion)(dispatchSpy, getState)
equal(apiClient.unsubscribeFromTopic.callCount, 1)
deepEqual(apiClient.unsubscribeFromTopic.firstCall.args, [this.getState(), discussion])
deepEqual(apiClient.unsubscribeFromTopic.firstCall.args, [getState(), discussion])
})
test('calls subscribeToTopic if the discussion is currently unsubscribed', function() {
test('calls subscribeToTopic if the discussion is currently unsubscribed', () => {
const dispatchSpy = sinon.spy()
const discussion = { id: 1, subscribed: false }
mockSuccess('subscribeToTopic', {})
actions.toggleSubscriptionState(discussion)(this.dispatchSpy, this.getState)
actions.toggleSubscriptionState(discussion)(dispatchSpy, getState)
equal(apiClient.subscribeToTopic.callCount, 1)
deepEqual(apiClient.subscribeToTopic.firstCall.args, [this.getState(), discussion])
deepEqual(apiClient.subscribeToTopic.firstCall.args, [getState(), discussion])
})
test('dispatches toggleSubscribeSuccess with unsubscription status if currently subscribed', function(assert) {
test('dispatches toggleSubscribeSuccess with unsubscription status if currently subscribed', (assert) => {
const dispatchSpy = sinon.spy()
const done = assert.async()
const discussion = { id: 1, subscribed: true }
mockSuccess('unsubscribeFromTopic', {})
actions.toggleSubscriptionState(discussion)(this.dispatchSpy, this.getState)
actions.toggleSubscriptionState(discussion)(dispatchSpy, getState)
setTimeout(() => {
const expectedArgs = [{
payload: { id: 1, subscribed: false },
type: "TOGGLE_SUBSCRIBE_SUCCESS"
}]
deepEqual(this.dispatchSpy.secondCall.args, expectedArgs)
deepEqual(dispatchSpy.secondCall.args, expectedArgs)
done()
})
})
test('dispatches toggleSubscribeSuccess with subscription status if currently unsubscribed', function(assert) {
test('dispatches toggleSubscribeSuccess with subscription status if currently unsubscribed', (assert) => {
const dispatchSpy = sinon.spy()
const done = assert.async()
const discussion = { id: 1, subscribed: false }
mockSuccess('subscribeToTopic', {})
actions.toggleSubscriptionState(discussion)(this.dispatchSpy, this.getState)
actions.toggleSubscriptionState(discussion)(dispatchSpy, getState)
setTimeout(() => {
const expectedArgs = [{
payload: { id: 1, subscribed: true },
type: "TOGGLE_SUBSCRIBE_SUCCESS"
}]
deepEqual(this.dispatchSpy.secondCall.args, expectedArgs)
deepEqual(dispatchSpy.secondCall.args, expectedArgs)
done()
})
})
test('dispatches toggleSubscribeFail in an error occures on the API call', function(assert) {
test('dispatches toggleSubscribeFail in an error occures on the API call', (assert) => {
const dispatchSpy = sinon.spy()
const done = assert.async()
const flashStub = sinon.spy($, 'screenReaderFlashMessageExclusive')
const discussion = { id: 1, subscribed: false }
mockFail('subscribeToTopic', "test error message")
actions.toggleSubscriptionState(discussion)(this.dispatchSpy, this.getState)
actions.toggleSubscriptionState(discussion)(dispatchSpy, getState)
setTimeout(() => {
const expectedArgs = [{
payload: { message: 'Subscribe failed', err: "test error message" },
type: "TOGGLE_SUBSCRIBE_FAIL"
}]
deepEqual(this.dispatchSpy.secondCall.args, expectedArgs)
deepEqual(dispatchSpy.secondCall.args, expectedArgs)
deepEqual(flashStub.firstCall.args, ["Subscribe failed"]);
flashStub.restore()
done()
})
})
test('saveSettings dispatches SAVING_SETTINGS_START', () => {
test('saveSettings dispatches SAVING_SETTINGS_START', (assert) => {
const done = assert.async()
mockSuccess('saveUserSettings', {})
mockSuccess('saveCourseSettings', {})
const courseSettings = {
allow_student_discussion_topics: true,
allow_student_forum_attachments: true,
@ -282,12 +443,15 @@ test('saveSettings dispatches SAVING_SETTINGS_START', () => {
const state = {contextId: "1", currentUserId: "1", userSettings}
const dispatchSpy = sinon.spy()
actions.saveSettings(userSettings, courseSettings)(dispatchSpy, () => state)
const expected = [
{
type: "SAVING_SETTINGS_START"
}
]
deepEqual(dispatchSpy.firstCall.args, expected)
setTimeout(() => {
const expected = [
{
type: "SAVING_SETTINGS_START"
}
]
deepEqual(dispatchSpy.firstCall.args, expected)
done()
})
})
test('saveSettings calls screenReaderFlash if successful with only user settings', (assert) => {
@ -359,6 +523,7 @@ test('saveSettings calls screenReaderFlash if failed with course settings', (ass
const dispatchSpy = sinon.spy()
const flashStub = sinon.spy($, 'screenReaderFlashMessage')
mockSuccess('saveUserSettings', {})
mockFail('saveCourseSettings', {})
actions.saveSettings(userSettings, courseSettings)(dispatchSpy, () => state)
@ -369,43 +534,46 @@ test('saveSettings calls screenReaderFlash if failed with course settings', (ass
})
})
test('calls api for duplicating if requested', function() {
test('calls api for duplicating if requested', () => {
const dispatchSpy = sinon.spy()
mockSuccess('duplicateDiscussion', {})
actions.duplicateDiscussion(1)(this.dispatchSpy, this.getState)
actions.duplicateDiscussion(1)(dispatchSpy, getState)
equal(apiClient.duplicateDiscussion.callCount, 1)
deepEqual(apiClient.duplicateDiscussion.firstCall.args, [this.getState(), 1])
deepEqual(apiClient.duplicateDiscussion.firstCall.args, [getState(), 1])
})
test('dispatches duplicateDiscussionSuccess if api call succeeds', function(assert) {
test('dispatches duplicateDiscussionSuccess if api call succeeds', (assert) => {
const dispatchSpy = sinon.spy()
const done = assert.async()
mockSuccess('duplicateDiscussion', { data: { id: 3 }})
actions.duplicateDiscussion(1)(this.dispatchSpy, this.getState)
actions.duplicateDiscussion(1)(dispatchSpy, getState)
setTimeout(() => {
const expectedArgs = [{
payload: { originalId: 1, newDiscussion: { id: 3 }},
type: "DUPLICATE_DISCUSSION_SUCCESS"
}]
deepEqual(this.dispatchSpy.secondCall.args, expectedArgs)
deepEqual(dispatchSpy.secondCall.args, expectedArgs)
done()
})
})
test('dispatches duplicateDiscussionFail if api call fails', function(assert) {
test('dispatches duplicateDiscussionFail if api call fails', (assert) => {
const dispatchSpy = sinon.spy()
const done = assert.async()
const discussion = { id: 1 }
mockFail('duplicateDiscussion', "YOU FAILED, YOU IDIOT")
actions.duplicateDiscussion(discussion.id)(this.dispatchSpy, this.getState)
actions.duplicateDiscussion(discussion.id)(dispatchSpy, getState)
setTimeout(() => {
const expectedArgs = [{
payload: { message: 'Duplication failed', err: "YOU FAILED, YOU IDIOT" },
type: "DUPLICATE_DISCUSSION_FAIL"
}]
deepEqual(this.dispatchSpy.secondCall.args, expectedArgs)
deepEqual(dispatchSpy.secondCall.args, expectedArgs)
done()
})
})
test('searchDiscussions dispatches UPDATE_DISCUSSIONS_SEARCH', function() {
test('searchDiscussions dispatches UPDATE_DISCUSSIONS_SEARCH', () => {
const dispatchSpy = sinon.spy()
const state = {
pinnedDiscussions: [],
@ -425,7 +593,7 @@ test('searchDiscussions dispatches UPDATE_DISCUSSIONS_SEARCH', function() {
deepEqual(dispatchSpy.firstCall.args, expected)
})
test('searchDiscussions announces number of results found to screenreader', function(assert) {
test('searchDiscussions announces number of results found to screenreader', (assert) => {
const done = assert.async()
const dispatchSpy = sinon.spy()
const flashStub = sinon.spy($, 'screenReaderFlashMessageExclusive')

View File

@ -23,6 +23,24 @@ QUnit.module('Discussions reducer')
const reduce = (action, state = {}) => reducer(state, action)
test('GET_DISCUSSIONS_SUCCESS sets sortableId for drag and drop', () => {
const dispatchData = {
data: [
{id: 1, pinned: true, position: 2},
{id: 2, pinned: true, position: 1},
]
}
const newState = reduce(actions.getDiscussionsSuccess(dispatchData), {
pinnedDiscussions: [],
unpinnedDiscussions: [],
closedForCommentsDiscussions: [],
})
deepEqual(newState.pinnedDiscussions.map(d => d.sortableId), [0, 1])
})
test('GET_DISCUSSIONS_SUCCESS properly sorts discussions', () => {
const dispatchData = {
data: [
@ -249,10 +267,10 @@ test('ARRANGE_PINNED_DISCUSSIONS should update unpinned discussion', () => {
]
})
deepEqual(newState.pinnedDiscussions, [
{ title: "aaron", id: 10, pinned: true, locked: false },
{ title: "venk", id: 5, pinned: true, locked: false },
{ title: "steven", id: 2, pinned: true, locked: false },
{ title: "landon", id: 1, pinned: true, locked: false }
{ title: "aaron", id: 10, pinned: true, locked: false, sortableId: 0},
{ title: "venk", id: 5, pinned: true, locked: false, sortableId: 1 },
{ title: "steven", id: 2, pinned: true, locked: false, sortableId: 2 },
{ title: "landon", id: 1, pinned: true, locked: false, sortableId: 3 }
])
})
@ -280,10 +298,10 @@ test('DUPLICATE_DISCUSSIONS_SUCCESS should update pinned discussion positions',
const newState = reduce(actions.duplicateDiscussionSuccess(payload), originalState)
const expectedPinnedDiscussions = [
{ title: "landon", id: 2, position: 20, pinned: true, locked: false },
{ title: "steven", id: 3, position: 21, pinned: true, locked: false },
{ title: "steven Copy", id: 5, position: 22, pinned: true, locked: false, focusOn: 'title'},
{ title: "aaron", id: 4, position: 23, pinned: true, locked: false },
{ title: "landon", id: 2, position: 20, sortableId: 0, pinned: true, locked: false },
{ title: "steven", id: 3, position: 21, pinned: true, sortableId: 1, locked: false },
{ title: "steven Copy", id: 5, position: 22, pinned: true, sortableId: 2, locked: false, focusOn: 'title'},
{ title: "aaron", id: 4, position: 23, pinned: true, sortableId: 3, locked: false },
]
deepEqual(newState.pinnedDiscussions, expectedPinnedDiscussions)
deepEqual(newState.closedForCommentsDiscussions, [])
@ -369,9 +387,9 @@ test('DELETE_DISCUSSION_SUCCESS should delete correct discussion', () => {
closedDiscussions: [],
})
deepEqual(newState.pinnedDiscussions, [
{ title: "landon", id: 1, pinned: true, locked: false, focusOn: "manageMenu" },
{ title: "steven", id: 2, pinned: true, locked: false },
{ title: "aaron", id: 10, pinned: true, locked: false }
{ title: "landon", id: 1, pinned: true, sortableId: 0, locked: false, focusOn: "manageMenu" },
{ title: "steven", id: 2, pinned: true, sortableId: 1, locked: false },
{ title: "aaron", id: 10, pinned: true, sortableId: 2, locked: false }
])
})
@ -410,3 +428,92 @@ test('DELETE_DISCUSSION_SUCCESS should not delete discussions not specified', ()
{ title: "me-aaron", id: 10, pinned: true, locked: false }
])
})
test('DRAG_AND_DROP should add sortableId to pinned discussion and sort correctly', () => {
const initialState = {
pinnedDiscussions: [
{id: 1, pinned: true, sortableId: 0},
{id: 2, pinned: true, sortableId: 1},
],
unpinnedDiscussions: [
{id: 3, pinned: false},
],
closedForCommentsDiscussions: []
}
const dispatchData = {
order: [3, 2, 1],
discussion: {id: 3, pinned: true}
}
// start and failure should do the same thing here
const states = [
reduce(actions.dragAndDropStart(dispatchData), initialState),
reduce(actions.dragAndDropFail(dispatchData), initialState)
]
states.forEach(newState => {
deepEqual(newState.pinnedDiscussions.map(d => d.sortableId), [0, 1, 2])
deepEqual(newState.pinnedDiscussions.map(d => d.id), [3, 2, 1])
deepEqual(newState.unpinnedDiscussions, [])
deepEqual(newState.closedForCommentsDiscussions, [])
})
})
test('DRAG_AND_DROP should put pinned discussion without ordering at the bottom', () => {
const initialState = {
pinnedDiscussions: [
{id: 1, pinned: true, sortableId: 0},
{id: 2, pinned: true, sortableId: 1},
],
unpinnedDiscussions: [
{id: 3, pinned: false},
],
closedForCommentsDiscussions: []
}
const dispatchData = {
order: undefined,
discussion: {id: 3, pinned: true}
}
// start and failure should do the same thing here
const states = [
reduce(actions.dragAndDropStart(dispatchData), initialState),
reduce(actions.dragAndDropFail(dispatchData), initialState)
]
states.forEach(newState => {
deepEqual(newState.pinnedDiscussions.map(d => d.sortableId), [0, 1, 2])
deepEqual(newState.pinnedDiscussions.map(d => d.id), [1, 2, 3])
deepEqual(newState.unpinnedDiscussions, [])
deepEqual(newState.closedForCommentsDiscussions, [])
})
})
test('DRAG_AND_DROP should remove sortableId to unpinned discussion', () => {
const initialState = {
pinnedDiscussions: [
{id: 1, pinned: true, sortableId: 0},
{id: 2, pinned: true, sortableId: 1},
],
unpinnedDiscussions: [
{id: 3, pinned: false},
],
closedForCommentsDiscussions: []
}
const dispatchData = {
order: undefined,
discussion: {id: 2, pinned: false}
}
// start and failure should do the same thing here
const states = [
reduce(actions.dragAndDropStart(dispatchData), initialState),
reduce(actions.dragAndDropFail(dispatchData), initialState)
]
states.forEach(newState => {
deepEqual(newState.pinnedDiscussions.map(d => d.id), [1])
deepEqual(newState.unpinnedDiscussions.map(d => d.id), [2, 3])
deepEqual(newState.unpinnedDiscussions.map(d => d.sortableId), [undefined, undefined])
deepEqual(newState.closedForCommentsDiscussions, [])
})
})