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:
parent
c2be7ad7ec
commit
e25f4cdac7
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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'
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in New Issue