initiate direct share course copy

closes ADMIN-2813

test plan:
- as a teacher on the discussions index page, open the "copy to" dialog
for a discussion
- select a course and click the copy button
- an alert is shown stating the copy has started
- allow the background job to run
- copy of the discussion should appear in the selected course when the
copy is complete

Change-Id: I808ce6d5b94dbf59950fc476a4e64519d374df9b
Reviewed-on: https://gerrit.instructure.com/209328
Tested-by: Jenkins
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
QA-Review: Jeremy Stanley <jeremy@instructure.com>
Product-Review: Jon Willesen <jonw+gerrit@instructure.com>
This commit is contained in:
Jon Willesen 2019-09-12 17:22:32 -06:00 committed by Jon Willesen
parent c2be7ad7ec
commit e25f4cdac7
13 changed files with 256 additions and 29 deletions

View File

@ -31,6 +31,7 @@ const app = createDiscussionsIndex(root, {
contextCodes: [ENV.context_asset_string],
currentUserId: ENV.current_user.id,
DIRECT_SHARE_ENABLED: ENV.DIRECT_SHARE_ENABLED,
COURSE_ID: ENV.COURSE_ID,
contextType,
contextId
})

View File

@ -66,6 +66,7 @@ const types = [
'DELETE_FOCUS_PENDING',
'DELETE_FOCUS_CLEANUP',
'CLEAN_DISCUSSION_FOCUS',
'SET_COPY_TO',
'SET_COPY_TO_OPEN',
'SET_SEND_TO_OPEN'
]

View File

@ -112,7 +112,7 @@ export class DiscussionRow extends Component {
connectDropTarget: func,
contextType: string.isRequired,
deleteDiscussion: func.isRequired,
setCopyToOpen: func.isRequired,
setCopyTo: func.isRequired,
setSendToOpen: func.isRequired,
discussion: discussionShape.isRequired,
discussionTopicMenuTools: arrayOf(propTypes.discussionTopicMenuTools),
@ -214,7 +214,10 @@ export class DiscussionRow extends Component {
)
break
case 'copyTo':
this.props.setCopyToOpen(true)
this.props.setCopyTo({
open: true,
selection: {discussion_topics: [this.props.discussion.id]}
})
break
case 'sendTo':
this.props.setSendToOpen(true)
@ -821,7 +824,7 @@ const mapDispatch = dispatch => {
'duplicateDiscussion',
'toggleSubscriptionState',
'updateDiscussion',
'setCopyToOpen',
'setCopyTo',
'setSendToOpen'
]
return bindActionCreators(select(actions, actionKeys), dispatch)

View File

@ -18,7 +18,7 @@
import I18n from 'i18n!discussions_v2'
import React, {Component} from 'react'
import {func, bool, string} from 'prop-types'
import {func, bool, string, shape, arrayOf} from 'prop-types'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {DragDropContext} from 'react-dnd'
@ -67,8 +67,10 @@ export default class DiscussionsIndex extends Component {
pinnedDiscussions: discussionList.isRequired,
unpinnedDiscussions: discussionList.isRequired,
copyToOpen: bool.isRequired,
copyToSelection: shape({discussion_topics: arrayOf(string)}),
sendToOpen: bool.isRequired,
DIRECT_SHARE_ENABLED: bool.isRequired
DIRECT_SHARE_ENABLED: bool.isRequired,
COURSE_ID: string
}
state = {
@ -246,6 +248,8 @@ export default class DiscussionsIndex extends Component {
)}
{this.props.DIRECT_SHARE_ENABLED && (
<DirectShareCourseTray
sourceCourseId={this.props.COURSE_ID}
contentSelection={this.props.copyToSelection}
open={this.props.copyToOpen}
onDismiss={() => this.props.setCopyToOpen(false)}
/>
@ -293,9 +297,11 @@ const connectState = (state, ownProps) => {
permissions: state.permissions,
pinnedDiscussions: pinnedDiscussionIds.map(id => allDiscussions[id]),
unpinnedDiscussions: unpinnedDiscussionIds.map(id => allDiscussions[id]),
copyToOpen: state.copyToOpen,
copyToOpen: state.copyTo.open,
copyToSelection: state.copyTo.selection,
sendToOpen: state.sendToOpen,
DIRECT_SHARE_ENABLED: state.DIRECT_SHARE_ENABLED
DIRECT_SHARE_ENABLED: state.DIRECT_SHARE_ENABLED,
COURSE_ID: state.COURSE_ID
}
return {...ownProps, ...fromPagination, ...fromState}
}

View File

@ -0,0 +1,45 @@
/*
* Copyright (C) 2018 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {handleActions} from 'redux-actions'
import {actionTypes} from '../actions'
function setCopyTo(state, action) {
const newState = {...state}
if (typeof action.payload.open === 'boolean') {
newState.open = action.payload.open
}
if (typeof action.payload.selection === 'object') {
newState.selection = action.payload.selection
}
return newState
}
function setCopyToOpen(state, action) {
return {...state, open: action.payload}
}
const reducer = handleActions(
{
[actionTypes.SET_COPY_TO_OPEN]: setCopyToOpen,
[actionTypes.SET_COPY_TO]: setCopyTo
},
{open: false, selection: {}}
)
export default reducer

View File

@ -30,6 +30,7 @@ import userSettingsReducer from './reducers/userSettingsReducer'
import courseSettingsReducer from './reducers/courseSettingsReducer'
import isSavingSettingsReducer from './reducers/isSavingSettingsReducer'
import isSettingsModalOpenReducer from './reducers/isSettingsModalOpenReducer'
import copyToReducer from './reducers/copyToReducer'
const identity = (defaultState = null) => state => (state === undefined ? defaultState : state)
@ -53,7 +54,8 @@ export default combineReducers({
roles: identity({}),
unpinnedDiscussionIds: unpinnedDiscussionReducer,
userSettings: userSettingsReducer,
copyToOpen: handleAction(actionTypes.SET_COPY_TO_OPEN, (s, a) => a.payload, false),
copyTo: copyToReducer, // handleAction(actionTypes.SET_COPY_TO_OPEN, (s, a) => a.payload, false),
sendToOpen: handleAction(actionTypes.SET_SEND_TO_OPEN, (s, a) => a.payload, false),
DIRECT_SHARE_ENABLED: identity(false)
DIRECT_SHARE_ENABLED: identity(false),
COURSE_ID: identity(null)
})

View File

@ -19,38 +19,98 @@
import I18n from 'i18n!direct_share_course_panel'
import React, {useState} from 'react'
import {func} from 'prop-types'
import {func, string} from 'prop-types'
import {Alert} from '@instructure/ui-alerts'
import {Button} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-layout'
import {Spinner} from '@instructure/ui-elements'
import doFetchApi from 'jsx/shared/effects/doFetchApi'
import contentSelectionShape from 'jsx/shared/proptypes/contentSelection'
import ManagedCourseSelector from '../components/ManagedCourseSelector'
// eventually this will have options for where to place the item in the new course.
// for now, it just has the selector plus some buttons
DirectShareCoursePanel.propTypes = {
onStart: func, // (course)
sourceCourseId: string,
contentSelection: contentSelectionShape,
onCancel: func
}
export default function DirectShareCoursePanel(props) {
function getLiveRegion() {
return document.querySelector('#flash_screenreader_holder')
}
export default function DirectShareCoursePanel({sourceCourseId, contentSelection, onCancel}) {
const [selectedCourse, setSelectedCourse] = useState(null)
const [postStatus, setPostStatus] = useState(null)
function startCopyOperation() {
return doFetchApi({
method: 'POST',
path: `/api/v1/courses/${selectedCourse.id}/content_migrations`,
body: {
migration_type: 'course_copy_importer',
select: contentSelection,
settings: {source_course_id: sourceCourseId}
},
})
}
function handleStart() {
if (props.onStart) props.onStart(selectedCourse)
console.log('TODO: start copy on course', selectedCourse)
setPostStatus('starting')
startCopyOperation()
.then(() => {
setPostStatus('success')
})
.catch(err => {
console.error(err) // eslint-disable-line no-console
if (err.response) console.error(err.response) // eslint-disable-line no-console
setPostStatus('error')
})
}
let alert = null
const alertProps = {
margin: 'small 0',
liveRegion: getLiveRegion
}
if (postStatus === 'error') {
alert = (
<Alert variant="error" {...alertProps}>
{I18n.t('There was a problem starting the copy operation')}
</Alert>
)
} else if (postStatus === 'success') {
alert = (
<Alert variant="success" {...alertProps}>
{I18n.t('Copy operation started successfully')}
</Alert>
)
} else if (postStatus === 'starting') {
alert = (
<Alert variant="info" {...alertProps}>
{I18n.t('Starting copy operation')}
<Spinner renderTitle="" size="x-small" />
</Alert>
)
}
const copyButtonDisabled = selectedCourse === null || postStatus !== null
return (
<>
{alert}
<ManagedCourseSelector onCourseSelected={setSelectedCourse} />
<Flex justifyItems="end" padding="small 0 0 0">
<Flex.Item>
<Button variant="primary" disabled={selectedCourse === null} onClick={handleStart}>
<Button variant="primary" disabled={copyButtonDisabled} onClick={handleStart}>
{I18n.t('Copy')}
</Button>
<Button margin="0 0 0 x-small" onClick={props.onCancel}>
<Button margin="0 0 0 x-small" onClick={onCancel}>
{I18n.t('Cancel')}
</Button>
</Flex.Item>

View File

@ -24,7 +24,7 @@ import CanvasTray from 'jsx/shared/components/CanvasTray'
const DirectShareCoursePanel = lazy(() => import('jsx/shared/direct_share/DirectShareCoursePanel'))
export default function DirectShareCourseTray({...trayProps}) {
export default function DirectShareCourseTray({sourceCourseId, contentSelection, onDismiss, ...trayProps}) {
const suspenseFallback = (
<View as="div" textAlign="center">
<Spinner renderTitle={I18n.t('Loading...')} />
@ -32,10 +32,14 @@ export default function DirectShareCourseTray({...trayProps}) {
)
return (
<CanvasTray label={I18n.t('Copy To...')} placement="end" {...trayProps}>
<CanvasTray label={I18n.t('Copy To...')} placement="end" onDismiss={onDismiss} {...trayProps}>
<View as="div" padding="small 0 0 0">
<Suspense fallback={suspenseFallback}>
<DirectShareCoursePanel onCancel={trayProps.onDismiss} />
<DirectShareCoursePanel
sourceCourseId={sourceCourseId}
contentSelection={contentSelection}
onCancel={onDismiss}
/>
</Suspense>
</View>
</CanvasTray>

View File

@ -17,7 +17,8 @@
*/
import React from 'react'
import {render, fireEvent} from '@testing-library/react'
import {render, fireEvent, act} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import useManagedCourseSearchApi from 'jsx/shared/effects/useManagedCourseSearchApi'
import DirectShareCoursePanel from '../DirectShareCoursePanel'
@ -43,6 +44,10 @@ describe('DirectShareCoursePanel', () => {
})
})
afterEach(() => {
fetchMock.restore()
})
it('disables the copy button initially', () => {
const {getByText} = render(<DirectShareCoursePanel />)
expect(
@ -59,11 +64,9 @@ describe('DirectShareCoursePanel', () => {
fireEvent.click(getByText('abc'))
const copyButton = getByText(/copy/i).closest('button')
expect(copyButton.getAttribute('disabled')).toBe(null)
fireEvent.click(copyButton)
expect(handleStart).toHaveBeenCalledWith({id: 'abc', name: 'abc'})
})
it('disables the copy button again when a search is initiated', () => {
it('disables the copy button again when a course search is initiated', () => {
const {getByText, getByLabelText} = render(<DirectShareCoursePanel />)
const input = getByLabelText(/select a course/i)
fireEvent.click(input)
@ -82,4 +85,56 @@ describe('DirectShareCoursePanel', () => {
fireEvent.click(getByText(/cancel/i))
expect(handleCancel).toHaveBeenCalled()
})
it('starts a copy operation and reports status', async () => {
fetchMock.postOnce(
'path:/api/v1/courses/abc/content_migrations',
{id: '8', workflow_state: 'running'}
)
const {getByText, getAllByText, getByLabelText} = render(
<DirectShareCoursePanel
sourceCourseId="42"
contentSelection={{discussion_topics: ['1123']}}
/>)
const input = getByLabelText(/select a course/i)
fireEvent.click(input)
fireEvent.click(getByText('abc'))
const copyButton = getByText(/copy/i).closest('button')
fireEvent.click(copyButton)
expect(copyButton.getAttribute('disabled')).toBe('')
const [, fetchOptions] = fetchMock.lastCall()
expect(fetchOptions.method).toBe('POST')
expect(JSON.parse(fetchOptions.body)).toMatchObject({
migration_type: 'course_copy_importer',
select: {discussion_topics: ['1123']},
settings: {source_course_id: '42'}
})
expect(getAllByText(/start/i)).toHaveLength(2)
await act(() => fetchMock.flush(true))
expect(getAllByText(/success/)).toHaveLength(2)
expect(copyButton.getAttribute('disabled')).toBe('')
})
describe('errors', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation()
})
afterEach(() => {
console.error.mockRestore() // eslint-disable-line no-console
})
it('reports an error if the fetch fails', async () => {
fetchMock.postOnce('path:/api/v1/courses/abc/content_migrations', 400)
const {getByText, getAllByText, getByLabelText} = render(<DirectShareCoursePanel sourceCourseId="42" />)
const input = getByLabelText(/select a course/i)
fireEvent.click(input)
fireEvent.click(getByText('abc'))
const copyButton = getByText(/copy/i).closest('button')
fireEvent.click(copyButton)
await act(() => fetchMock.flush(true))
expect(getAllByText(/problem/i)).toHaveLength(2)
expect(copyButton.getAttribute('disabled')).toBe('')
})
})
})

View File

@ -85,11 +85,12 @@ describe('doFetchApi', () => {
})
})
it('converts body object to string body', () => {
it('converts body object to string body and sets content-type', () => {
const path = '/api/v1/blah'
fetchMock.mock(`path:${path}`, 200)
doFetchApi({path, body: {the: 'body'}})
const [, fetchOptions] = fetchMock.lastCall()
expect(JSON.parse(fetchOptions.body)).toEqual({the: 'body'})
expect(fetchOptions.headers['Content-Type']).toBe('application/json')
})
})

View File

@ -36,9 +36,13 @@ export default async function doFetchApi({
const fetchHeaders = {
Accept: 'application/json+canvas-string-ids, application/json',
'X-CSRF-Token': $.cookie('_csrf_token'),
...headers
}
if (body && typeof body !== 'string') body = JSON.stringify(body)
if (body && typeof body !== 'string') {
body = JSON.stringify(body)
fetchHeaders['Content-Type'] = 'application/json'
}
Object.assign(fetchHeaders, headers)
const url = constructRelativeUrl({path, params})
const response = await fetch(url, {headers: fetchHeaders, body, method, ...fetchOpts})
if (!response.ok) {

View File

@ -0,0 +1,42 @@
/*
* Copyright (C) 2019 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {arrayOf, exact, number, oneOfType, string} from 'prop-types'
const CONTENT_SELECTION_TYPES = [
'folders',
'files',
'attachments',
'quizzes',
'assignments',
'announcements',
'calendar_events',
'discussion_topics',
'modules',
'module_items',
'pages',
'rubrics'
]
// build the shape {folders: arrayOf(...), files: arrayOf(...), ...}
const contentSelectionShape = exact(CONTENT_SELECTION_TYPES.reduce((selections, type) => {
selections[type] = arrayOf(oneOfType([string, number]))
return selections
}, {}))
export default contentSelectionShape

View File

@ -48,7 +48,7 @@ const makeProps = (props = {}) => merge({
},
canPublish: false,
masterCourseData: {},
setCopyToOpen: () => {},
setCopyTo: () => {},
setSendToOpen: () => {},
DIRECT_SHARE_ENABLED: false
}, props)
@ -390,7 +390,7 @@ test('opens the copyTo tray when menu item is selected', () => {
const props = makeProps({
displayManageMenu: true,
DIRECT_SHARE_ENABLED: true,
setCopyToOpen: copySpy,
setCopyTo: copySpy
})
const tree = mount(<DiscussionRow {...props} />)
tree
@ -398,7 +398,10 @@ test('opens the copyTo tray when menu item is selected', () => {
.find('button')
.simulate('click')
document.querySelector('#copyTo-discussion-menu-option').click()
ok(copySpy.calledOnce)
deepEqual(copySpy.firstCall.args[0], {
open: true,
selection: {discussion_topics: [props.discussion.id]}
})
tree.unmount()
})