Add module item positioning to direct share trays

flag=direct_share
closes LA-11

Test plan
- Go to the received content page and the
  copy content menu and ensure you can
  see the module item pickers
- Ensure you can copy content to all
  of the various positions in the picker
- Verify the copied content gets to the right
  places

Change-Id: I68678edb89d51691a3ee4e5713dfceb46693cf89
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/218105
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Tested-by: Jenkins
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
QA-Review: Jeremy Stanley <jeremy@instructure.com>
Product-Review: Zsofia Goreczky <zsgoreczky@instructure.com>
This commit is contained in:
Mysti Lilla 2019-11-15 14:07:22 -07:00
parent e1518e042b
commit b0044898f2
8 changed files with 345 additions and 22 deletions

View File

@ -33,6 +33,7 @@ CourseImportPanel.propTypes = {
export default function CourseImportPanel({contentShare, onClose}) {
const [selectedCourse, setSelectedCourse] = useState(null)
const [selectedModule, setSelectedModule] = useState(null)
const [selectedPosition, setSelectedPosition] = useState(1)
const [startImportOperationPromise, setStartImportOperationPromise] = useState(null)
function startImportOperation() {
@ -45,7 +46,8 @@ export default function CourseImportPanel({contentShare, onClose}) {
settings: {
content_export_id: contentShare.content_export.id,
insert_into_module_id: selectedModule?.id,
insert_into_module_type: contentShare.content_type
insert_into_module_type: contentShare.content_type,
insert_into_module_position: selectedPosition
}
}
})
@ -63,7 +65,9 @@ export default function CourseImportPanel({contentShare, onClose}) {
<CourseAndModulePicker
selectedCourseId={selectedCourse?.id}
setSelectedCourse={setSelectedCourse}
selectedModuleId={selectedModule?.id}
setSelectedModule={setSelectedModule}
setModuleItemPosition={setSelectedPosition}
/>
<ConfirmActionButtonBar
padding="small 0 0 0"

View File

@ -20,40 +20,58 @@ import I18n from 'i18n!course_and_module_picker'
import React from 'react'
import {func, string} from 'prop-types'
import {View} from '@instructure/ui-view'
import {Flex} from '@instructure/ui-flex'
import useManagedCourseSearchApi from '../effects/useManagedCourseSearchApi'
import useModuleCourseSearchApi from '../effects/useModuleCourseSearchApi'
import SearchItemSelector from 'jsx/shared/components/SearchItemSelector'
import ModulePositionPicker from './ModulePositionPicker'
CourseAndModulePicker.propTypes = {
selectedCourseId: string,
setSelectedCourse: func,
setSelectedModule: func
selectedModuleId: string,
setSelectedModule: func,
setModuleItemPosition: func
}
export default function CourseAndModulePicker({
selectedCourseId,
setSelectedCourse,
setSelectedModule
selectedModuleId,
setSelectedModule,
setModuleItemPosition
}) {
return (
<>
<SearchItemSelector
onItemSelected={setSelectedCourse}
renderLabel={I18n.t('Select a Course')}
itemSearchFunction={useManagedCourseSearchApi}
/>
{selectedCourseId && (
<View display="block" margin="medium 0 0">
<Flex direction="column">
<Flex.Item padding="small">
<SearchItemSelector
onItemSelected={setSelectedModule}
renderLabel={I18n.t('Select a Module (optional)')}
itemSearchFunction={useModuleCourseSearchApi}
contextId={selectedCourseId}
onItemSelected={setSelectedCourse}
renderLabel={I18n.t('Select a Course')}
itemSearchFunction={useManagedCourseSearchApi}
/>
</View>
)}
</Flex.Item>
<Flex.Item padding="small">
{selectedCourseId && (
<SearchItemSelector
onItemSelected={setSelectedModule}
renderLabel={I18n.t('Select a Module (optional)')}
itemSearchFunction={useModuleCourseSearchApi}
contextId={selectedCourseId}
/>
)}
</Flex.Item>
<Flex.Item padding="small">
{selectedCourseId && selectedModuleId && (
<ModulePositionPicker
courseId={selectedCourseId}
moduleId={selectedModuleId}
setModuleItemPosition={setModuleItemPosition}
/>
)}
</Flex.Item>
</Flex>
</>
)
}

View File

@ -40,6 +40,7 @@ export default function DirectShareCoursePanel({sourceCourseId, contentSelection
const [selectedCourse, setSelectedCourse] = useState(null)
const [startCopyOperationPromise, setStartCopyOperationPromise] = useState(null)
const [selectedModule, setSelectedModule] = useState(null)
const [selectedPosition, setSelectedPosition] = useState(null)
function startCopyOperation() {
setStartCopyOperationPromise(
@ -52,7 +53,8 @@ export default function DirectShareCoursePanel({sourceCourseId, contentSelection
settings: {
source_course_id: sourceCourseId,
insert_into_module_id: selectedModule?.id,
insert_into_module_type: contentSelection ? Object.keys(contentSelection)[0] : null
insert_into_module_type: contentSelection ? Object.keys(contentSelection)[0] : null,
insert_into_module_position: selectedPosition
}
}
})
@ -70,7 +72,9 @@ export default function DirectShareCoursePanel({sourceCourseId, contentSelection
<CourseAndModulePicker
selectedCourseId={selectedCourse?.id}
setSelectedCourse={setSelectedCourse}
selectedModuleId={selectedModule?.id}
setSelectedModule={setSelectedModule}
setModuleItemPosition={setSelectedPosition}
/>
<ConfirmActionButtonBar
padding="small 0 0 0"

View File

@ -0,0 +1,114 @@
/*
* 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 I18n from 'i18n!module_position_picker'
import React, {useState, useEffect} from 'react'
import {func, string} from 'prop-types'
import {useCourseModuleItemApi} from 'jsx/shared/effects/useModuleCourseSearchApi'
import SelectPosition from 'jsx/shared/helpers/SelectPosition'
import {positions} from 'jsx/move_item/positions'
ModulePositionPicker.propTypes = {
courseId: string.isRequired,
moduleId: string.isRequired,
setModuleItemPosition: func
}
ModulePositionPicker.defaultProps = {
setModuleItemPosition: () => {}
}
export default function ModulePositionPicker({courseId, moduleId, setModuleItemPosition}) {
const [moduleItems, setModuleItems] = useState([])
const [position, setPosition] = useState(null)
const [offset, setOffset] = useState(0)
const [siblingPosition, setSiblingPosition] = useState(1)
const [error, setError] = useState(null)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
setModuleItemPosition(calculateDefaultPosition() + offset)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId, moduleId])
const params = {contextId: courseId, moduleId}
useCourseModuleItemApi({
success: setModuleItems,
error: setError,
loading: setIsLoading,
fetchAllPages: true,
params
})
function calculateDefaultPosition() {
return parseInt(moduleItems[0]?.position || siblingPosition, 10)
}
if (error !== null) throw error
function handleSetPosition(e) {
const pos = e.target.value
setPosition(positions[pos])
switch (pos) {
case 'first':
setOffset(0)
setSiblingPosition(1)
setModuleItemPosition(1)
break
case 'last':
setOffset(0)
setSiblingPosition(1)
setModuleItemPosition(null)
break
case 'after':
setOffset(1)
// + 1 for the offset that won't be set yet by the time we need it
setModuleItemPosition(calculateDefaultPosition() + 1)
break
case 'before':
setOffset(0)
setModuleItemPosition(calculateDefaultPosition())
break
}
}
function handleSetSibling(e) {
const pos = parseInt(moduleItems[e.target.value]?.position, 10)
setSiblingPosition(pos)
setModuleItemPosition(pos + offset)
}
return (
<SelectPosition
items={[]}
siblings={
isLoading
? moduleItems.concat({
title: I18n.t('Loading additional items...'),
id: '0',
groupId: '0'
})
: moduleItems
}
selectedPosition={position}
selectPosition={handleSetPosition}
selectSibling={handleSetSibling}
/>
)
}

View File

@ -0,0 +1,169 @@
/*
* 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 React from 'react'
import {render, fireEvent} from '@testing-library/react'
import ModulePositionPicker from '../ModulePositionPicker'
import {useCourseModuleItemApi} from 'jsx/shared/effects/useModuleCourseSearchApi'
jest.mock('jsx/shared/effects/useModuleCourseSearchApi')
describe('ModulePositionPicker', () => {
it("shows 'loading additional items' when it's still loading data", () => {
useCourseModuleItemApi.mockImplementationOnce(({success, loading}) => {
success([{id: 'abc', title: 'abc', position: '1'}, {id: 'cde', title: 'cde', position: '2'}])
loading(true)
})
const {getByText, getByTestId} = render(<ModulePositionPicker courseId="1" moduleId="1" />)
fireEvent.change(getByTestId('select-position'), {target: {value: 'before'}})
expect(getByText('Loading additional items...')).toBeInTheDocument()
})
it('should not show the module items unless a relative position is chosen', () => {
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'abc', title: 'abc', position: '1'}, {id: 'cde', title: 'cde', position: '2'}])
})
const {getByText, getByTestId, queryByText} = render(
<ModulePositionPicker courseId="1" moduleId="1" />
)
expect(getByText(/At the Bottom/i)).toBeInTheDocument()
fireEvent.change(getByTestId('select-position'), {target: {value: 'last'}})
expect(queryByText('abc')).not.toBeInTheDocument()
expect(queryByText('cde')).not.toBeInTheDocument()
})
it('should show the module items when a relative position is chosen', () => {
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'abc', title: 'abc', position: '1'}, {id: 'cde', title: 'cde', position: '2'}])
})
const {getByText, getByTestId} = render(<ModulePositionPicker courseId="1" moduleId="1" />)
expect(getByText(/At the Top/i)).toBeInTheDocument()
fireEvent.change(getByTestId('select-position'), {target: {value: 'after'}})
expect(getByText('abc')).toBeInTheDocument()
expect(getByText('cde')).toBeInTheDocument()
})
it('should call setModuleItemPosition with 1 on load', () => {
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'abc', title: 'abc', position: '1'}, {id: 'cde', title: 'cde', position: '2'}])
})
const positionSetter = jest.fn()
render(
<ModulePositionPicker courseId="1" moduleId="1" setModuleItemPosition={positionSetter} />
)
expect(positionSetter).toHaveBeenCalledWith(1)
})
it('should call setModuleItemPosition with 1 when "at the top" is chosen', () => {
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'abc', title: 'abc', position: '1'}, {id: 'cde', title: 'cde', position: '2'}])
})
const positionSetter = jest.fn()
const {getByTestId} = render(
<ModulePositionPicker courseId="1" moduleId="1" setModuleItemPosition={positionSetter} />
)
fireEvent.change(getByTestId('select-position'), {target: {value: 'first'}})
expect(positionSetter).toHaveBeenCalledTimes(2)
expect(positionSetter).toHaveBeenLastCalledWith(1)
})
it('should call setModuleItemPosition with null when "at the bottom" is chosen', () => {
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'abc', title: 'abc', position: '1'}, {id: 'cde', title: 'cde', position: '2'}])
})
const positionSetter = jest.fn()
const {getByTestId} = render(
<ModulePositionPicker courseId="1" moduleId="1" setModuleItemPosition={positionSetter} />
)
fireEvent.change(getByTestId('select-position'), {target: {value: 'last'}})
expect(positionSetter).toHaveBeenCalledTimes(2)
expect(positionSetter).toHaveBeenLastCalledWith(null)
})
it('should call setModuleItemPosition with 1 when "before" is chosen with the default module item', () => {
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'abc', title: 'abc', position: '1'}, {id: 'cde', title: 'cde', position: '2'}])
})
const positionSetter = jest.fn()
const {getByTestId} = render(
<ModulePositionPicker courseId="1" moduleId="1" setModuleItemPosition={positionSetter} />
)
fireEvent.change(getByTestId('select-position'), {target: {value: 'before'}})
expect(positionSetter).toHaveBeenCalledTimes(2)
expect(positionSetter).toHaveBeenLastCalledWith(1)
})
it('should call setModuleItemPosition with 1 + position of first item when "after" is chosen with the default module item', () => {
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'abc', title: 'abc', position: '5'}, {id: 'cde', title: 'cde', position: '6'}])
})
const positionSetter = jest.fn()
const {getByTestId} = render(
<ModulePositionPicker courseId="1" moduleId="1" setModuleItemPosition={positionSetter} />
)
fireEvent.change(getByTestId('select-position'), {target: {value: 'after'}})
expect(positionSetter).toHaveBeenCalledTimes(2)
expect(positionSetter).toHaveBeenLastCalledWith(6)
})
it('should call setModuleItemPosition with the appropriate position when "before" is chosen with a non-default module item', () => {
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'abc', title: 'abc', position: '5'}, {id: 'cde', title: 'cde', position: '6'}])
})
const positionSetter = jest.fn()
const {getByTestId} = render(
<ModulePositionPicker courseId="1" moduleId="1" setModuleItemPosition={positionSetter} />
)
fireEvent.change(getByTestId('select-position'), {target: {value: 'before'}})
fireEvent.change(getByTestId('select-sibling'), {target: {value: '0'}})
expect(positionSetter).toHaveBeenCalledTimes(3)
expect(positionSetter).toHaveBeenLastCalledWith(5)
})
it('should call setModuleItemPosition with the appropriate position when "after" is chosen with a non-default module item', () => {
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'abc', title: 'abc', position: '5'}, {id: 'cde', title: 'cde', position: '6'}])
})
const positionSetter = jest.fn()
const {getByTestId} = render(
<ModulePositionPicker courseId="1" moduleId="1" setModuleItemPosition={positionSetter} />
)
fireEvent.change(getByTestId('select-position'), {target: {value: 'after'}})
fireEvent.change(getByTestId('select-sibling'), {target: {value: '1'}})
expect(positionSetter).toHaveBeenCalledTimes(3)
expect(positionSetter).toHaveBeenLastCalledWith(7)
})
it('should reset the module items and send a new position to setModuleItemPosition if a new module is chosen', () => {
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'abc', title: 'abc', position: '5'}, {id: 'cde', title: 'cde', position: '6'}])
})
const positionSetter = jest.fn()
const {rerender} = render(
<ModulePositionPicker courseId="1" moduleId="1" setModuleItemPosition={positionSetter} />
)
useCourseModuleItemApi.mockImplementationOnce(({success}) => {
success([{id: 'fgh', title: 'fgh', position: '7'}, {id: 'ijk', title: 'ijk', position: '8'}])
})
rerender(
<ModulePositionPicker courseId="1" moduleId="2" setModuleItemPosition={positionSetter} />
)
expect(positionSetter).toHaveBeenCalledTimes(2)
expect(positionSetter).toHaveBeenLastCalledWith(7)
})
})

View File

@ -26,3 +26,16 @@ export default function useModuleCourseSearchApi(fetchApiOpts) {
...fetchApiOpts
})
}
export function useCourseModuleItemApi(fetchApiOpts) {
const courseId = fetchApiOpts?.params?.contextId
const moduleId = fetchApiOpts?.params?.moduleId
if (courseId && moduleId) {
delete fetchApiOpts.params.contextId
delete fetchApiOpts.params.moduleId
}
useFetchApi({
path: `/api/v1/courses/${courseId}/modules/${moduleId}/items`,
...fetchApiOpts
})
}

View File

@ -50,6 +50,7 @@ export function RenderSelect({label, onChange, options, className, selectOneDefa
<select
data-testid={testId}
onChange={onChange}
className="move-select-form"
style={{
margin: '0',
width: '100%'
@ -87,7 +88,7 @@ export default function SelectPosition({
const positionSelected = !!(selectedPosition && selectedPosition.type === 'relative')
function renderSelectSibling() {
const filteredItems = siblings.filter(item => item.id !== items[0].id)
const filteredItems = siblings.filter(item => item.id !== items[0]?.id)
return (
<RenderSelect
label={I18n.t('Item Select')}
@ -106,7 +107,7 @@ export default function SelectPosition({
function renderPlaceTitle() {
const title =
items.length > 1 ? I18n.t('Place') : I18n.t('Place "%{title}"', {title: items[0].title})
items.length === 1 ? I18n.t('Place "%{title}"', {title: items[0].title}) : I18n.t('Place')
return <Text weight="bold">{title}</Text>
}

View File

@ -90,7 +90,7 @@ describe('SelectPosition', () => {
selectPosition={selectPosition}
/>
)
fireEvent.change(getByTestId('select-position', {target: {value: 'After..'}}))
fireEvent.change(getByTestId('select-position'), {target: {value: 'after'}})
expect(selectPosition).toHaveBeenCalled()
})
@ -107,7 +107,7 @@ describe('SelectPosition', () => {
selectSibling={selectSibling}
/>
)
fireEvent.change(getByTestId('select-sibling', {target: {value: 'Item 3'}}))
fireEvent.change(getByTestId('select-sibling'), {target: {value: 'Item 3'}})
expect(selectSibling).toHaveBeenCalled()
})
})