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:
parent
cc2f39ad36
commit
61f102711f
|
@ -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 }))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 []
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}))
|
||||
}
|
||||
|
|
|
@ -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'}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 /> {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 /> {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 /> {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 /> { 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 /> {I18n.t('Move To')}
|
||||
<IconCopySolid /> {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 /> {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 /> { 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 /> {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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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, [])
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue