bulk edit: select assignments by date
closes LA-933 flag=assignment_bulk_edit_phase_2 test plan: - The date range fields are only visible if the phase 2 flag is set - If both dates are blank, apply button is disabled. - If either date field is filled, apply button is enabled. - In the "select by date range", fields enter two dates. - If the end date is before the start date, an error should be displayed and the apply button disabled. - Click the apply button with a valid date range. All assignments with any date on any override that fall within the date range should be selected. All other assignments should not be selected - If only the start field is filled, it should select all assignments on that date or afterward. - If only the end field is filled, it should select all assignments on that date or before. - Assignments with no dates never get selected Change-Id: Ie5915a1e77f1b3117cd14c2818395fdde7343761 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/236971 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> QA-Review: Jeremy Stanley <jeremy@instructure.com> Product-Review: Lauren Williams <lcwilliams@instructure.com> Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
This commit is contained in:
parent
62583340bd
commit
ab9316b4fe
|
@ -21,9 +21,11 @@ import React, {useCallback, useEffect, useState, useMemo} from 'react'
|
|||
import {func, string} from 'prop-types'
|
||||
import moment from 'moment-timezone'
|
||||
import produce from 'immer'
|
||||
import {DateTime} from '@instructure/ui-i18n'
|
||||
import CanvasInlineAlert from 'jsx/shared/components/CanvasInlineAlert'
|
||||
import LoadingIndicator from 'jsx/shared/LoadingIndicator'
|
||||
import useFetchApi from 'jsx/shared/effects/useFetchApi'
|
||||
import BulkEditDateSelect from './BulkEditDateSelect'
|
||||
import BulkEditHeader from './BulkEditHeader'
|
||||
import BulkEditTable from './BulkEditTable'
|
||||
import MoveDatesModal from './MoveDatesModal'
|
||||
|
@ -230,6 +232,24 @@ export default function BulkEdit({courseId, onCancel, onSave}) {
|
|||
)
|
||||
}, [])
|
||||
|
||||
const selectDateRange = useCallback((startDate, endDate) => {
|
||||
const timezone = ENV?.TIMEZONE || DateTime.browserTimeZone()
|
||||
const startMoment = moment.tz(startDate, timezone).startOf('day')
|
||||
const endMoment = moment.tz(endDate, timezone).endOf('day')
|
||||
setAssignments(currentAssignments =>
|
||||
produce(currentAssignments, draftAssignments => {
|
||||
draftAssignments.forEach(draftAssignment => {
|
||||
const shouldSelect = draftAssignment.all_dates.some(draftOverride =>
|
||||
['due_at', 'lock_at', 'unlock_at'].some(dateField =>
|
||||
moment(draftOverride[dateField]).isBetween(startMoment, endMoment, null, '[]')
|
||||
)
|
||||
)
|
||||
draftAssignment.selected = shouldSelect
|
||||
})
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave()
|
||||
saveAssignments(assignments)
|
||||
|
@ -254,10 +274,9 @@ export default function BulkEdit({courseId, onCancel, onSave}) {
|
|||
})
|
||||
})
|
||||
)
|
||||
selectAllAssignments(false)
|
||||
setMoveDatesModalOpen(false)
|
||||
},
|
||||
[selectAllAssignments, shiftDateOnOverride]
|
||||
[shiftDateOnOverride]
|
||||
)
|
||||
const handleBatchEditRemove = useCallback(
|
||||
datesToRemove => {
|
||||
|
@ -277,10 +296,9 @@ export default function BulkEdit({courseId, onCancel, onSave}) {
|
|||
})
|
||||
})
|
||||
)
|
||||
selectAllAssignments(false)
|
||||
setMoveDatesModalOpen(false)
|
||||
},
|
||||
[selectAllAssignments, setDateOnOverride]
|
||||
[setDateOnOverride]
|
||||
)
|
||||
|
||||
function renderHeader() {
|
||||
|
@ -297,6 +315,14 @@ export default function BulkEdit({courseId, onCancel, onSave}) {
|
|||
return <BulkEditHeader {...headerProps} />
|
||||
}
|
||||
|
||||
function renderDateSelect() {
|
||||
return (
|
||||
ENV.FEATURES.assignment_bulk_edit_phase_2 && (
|
||||
<BulkEditDateSelect selectDateRange={selectDateRange} />
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function renderSaveSuccess() {
|
||||
if (jobSuccess) {
|
||||
return (
|
||||
|
@ -380,6 +406,7 @@ export default function BulkEdit({courseId, onCancel, onSave}) {
|
|||
{renderSaveSuccess()}
|
||||
{renderSaveError()}
|
||||
{renderHeader()}
|
||||
{renderDateSelect()}
|
||||
{renderBody()}
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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!assignments_bulk_edit'
|
||||
import React, {useState, useCallback} from 'react'
|
||||
import tz from 'timezone'
|
||||
|
||||
import {Button} from '@instructure/ui-buttons'
|
||||
import {Flex} from '@instructure/ui-flex'
|
||||
import {FormFieldGroup} from '@instructure/ui-form-field'
|
||||
import {ScreenReaderContent, PresentationContent} from '@instructure/ui-a11y-content'
|
||||
import {Text} from '@instructure/ui-text'
|
||||
import {View} from '@instructure/ui-view'
|
||||
import CanvasDateInput from 'jsx/shared/components/CanvasDateInput'
|
||||
|
||||
function formatDate(date) {
|
||||
return tz.format(date, 'date.formats.medium_with_weekday')
|
||||
}
|
||||
|
||||
export default function({selectDateRange}) {
|
||||
const [startDate, setStartDate] = useState(null)
|
||||
const [endDate, setEndDate] = useState(null)
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const start = startDate || new Date(-100000, 1)
|
||||
const end = endDate || new Date(100000, 0)
|
||||
selectDateRange(start, end)
|
||||
}, [endDate, startDate, selectDateRange])
|
||||
|
||||
function outOfOrder() {
|
||||
return startDate && endDate && startDate > endDate
|
||||
}
|
||||
function canApply() {
|
||||
return (startDate || endDate) && !outOfOrder()
|
||||
}
|
||||
|
||||
function messages() {
|
||||
return outOfOrder()
|
||||
? [{text: I18n.t('The end date must be after the start date'), type: 'error'}]
|
||||
: []
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex margin="0 0 medium 0">
|
||||
{/* Using a flex to force FormFieldGroup to shrink and not be full width */}
|
||||
<Flex.Item>
|
||||
<FormFieldGroup
|
||||
description={I18n.t('Select by date range')}
|
||||
layout="columns"
|
||||
colSpacing="small"
|
||||
messages={messages()}
|
||||
>
|
||||
{/* Use a View to trick FormFieldGroup into having one child for layout purposes */}
|
||||
{/* Otherwise all the children get equal space, and we don't want that */}
|
||||
<View>
|
||||
<CanvasDateInput
|
||||
selectedDate={startDate}
|
||||
renderLabel={
|
||||
<ScreenReaderContent>{I18n.t('Selection start date')}</ScreenReaderContent>
|
||||
}
|
||||
formatDate={formatDate}
|
||||
onSelectedDateChange={setStartDate}
|
||||
/>
|
||||
<View as="span" margin="0 small">
|
||||
<Text weight="bold">{I18n.t('to')}</Text>
|
||||
</View>
|
||||
<CanvasDateInput
|
||||
selectedDate={endDate}
|
||||
renderLabel={
|
||||
<ScreenReaderContent>{I18n.t('Selection end date')}</ScreenReaderContent>
|
||||
}
|
||||
formatDate={formatDate}
|
||||
onSelectedDateChange={setEndDate}
|
||||
/>
|
||||
<Button
|
||||
margin="0 0 0 small"
|
||||
size="small"
|
||||
interaction={canApply() ? 'enabled' : 'disabled'}
|
||||
onClick={handleApply}
|
||||
>
|
||||
<ScreenReaderContent>{I18n.t('Apply date range selection')}</ScreenReaderContent>
|
||||
<PresentationContent>{I18n.t('Apply')}</PresentationContent>
|
||||
</Button>
|
||||
</View>
|
||||
</FormFieldGroup>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
)
|
||||
}
|
|
@ -673,6 +673,184 @@ describe('Assignment Bulk Edit Dates', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('assignment selection by date', () => {
|
||||
beforeEach(() => {
|
||||
window.ENV.FEATURES.assignment_bulk_edit_phase_2 = true
|
||||
})
|
||||
|
||||
function assignmentListWithDates() {
|
||||
return [
|
||||
{
|
||||
id: 'assignment_1',
|
||||
name: 'First Assignment',
|
||||
can_edit: true,
|
||||
all_dates: [
|
||||
{
|
||||
base: true,
|
||||
unlock_at: moment.tz('2020-03-19T00:00:00', 'Asia/Tokyo').toISOString(),
|
||||
due_at: moment.tz('2020-03-20T11:59:59', 'Asia/Tokyo').toISOString(),
|
||||
lock_at: moment.tz('2020-04-11T11:59:59', 'Asia/Tokyo').toISOString(),
|
||||
can_edit: true
|
||||
},
|
||||
{
|
||||
id: 'override_1',
|
||||
title: '2 students',
|
||||
unlock_at: moment.tz('2020-03-29T00:00:00', 'Asia/Tokyo').toISOString(),
|
||||
due_at: moment.tz('2020-03-30T11:59:59', 'Asia/Tokyo').toISOString(),
|
||||
lock_at: moment.tz('2020-04-21T11:59:59', 'Asia/Tokyo').toISOString(),
|
||||
can_edit: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'assignment_2',
|
||||
name: 'second assignment',
|
||||
can_edit: true,
|
||||
all_dates: [
|
||||
{
|
||||
id: 'override_2',
|
||||
unlock_at: moment.tz('2020-03-22T00:00:00', 'Asia/Tokyo').toISOString(),
|
||||
due_at: moment.tz('2020-03-23T11:59:59', 'Asia/Tokyo').toISOString(),
|
||||
lock_at: null,
|
||||
can_edit: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'assignment_3',
|
||||
name: 'third assignment',
|
||||
can_edit: true,
|
||||
all_dates: [
|
||||
{
|
||||
base: true,
|
||||
unlock_at: moment.tz('2020-03-24T00:00:00', 'Asia/Tokyo').toISOString(),
|
||||
due_at: moment.tz('2020-03-25T11:59:59', 'Asia/Tokyo').toISOString(),
|
||||
lock_at: null,
|
||||
can_edit: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'assignment_4',
|
||||
name: 'fourth assignment',
|
||||
can_edit: true,
|
||||
all_dates: [
|
||||
{
|
||||
base: true,
|
||||
unlock_at: null,
|
||||
due_at: null,
|
||||
lock_at: null,
|
||||
can_edit: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
it('apply button is initially disabled when both fields are blank', async () => {
|
||||
const {getByText} = await renderBulkEditAndWait()
|
||||
expect(getByText(/Apply date range selection/).closest('button').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('apply button is enabled if either field is filled', async () => {
|
||||
const {getByText, getByLabelText} = await renderBulkEditAndWait()
|
||||
const applyButton = getByText(/Apply date range selection/)
|
||||
const startInput = getByLabelText('Selection start date')
|
||||
changeAndBlurInput(startInput, '2020-03-18')
|
||||
expect(applyButton.closest('button').disabled).toBe(false)
|
||||
changeAndBlurInput(startInput, '')
|
||||
expect(applyButton.closest('button').disabled).toBe(true)
|
||||
const endInput = getByLabelText('Selection end date')
|
||||
changeAndBlurInput(endInput, '2020-03-18')
|
||||
expect(applyButton.closest('button').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('selects some assignments between two dates', async () => {
|
||||
const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
|
||||
{},
|
||||
assignmentListWithDates()
|
||||
)
|
||||
const checkboxes = getAllByLabelText(/Select assignment:/)
|
||||
changeAndBlurInput(getByLabelText('Selection start date'), '2020-03-20')
|
||||
changeAndBlurInput(getByLabelText('Selection end date'), '2020-03-23')
|
||||
fireEvent.click(getByText(/^Apply$/))
|
||||
expect(checkboxes.map(cb => cb.checked)).toEqual([true, true, false, false])
|
||||
})
|
||||
|
||||
it('deselects assignments outside of the dates', async () => {
|
||||
const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
|
||||
{},
|
||||
assignmentListWithDates()
|
||||
)
|
||||
const checkboxes = getAllByLabelText(/Select assignment:/)
|
||||
checkboxes.forEach(cb => fireEvent.click(cb))
|
||||
expect(checkboxes.map(cb => cb.checked)).toEqual([true, true, true, true])
|
||||
changeAndBlurInput(getByLabelText('Selection start date'), '2020-03-20')
|
||||
changeAndBlurInput(getByLabelText('Selection end date'), '2020-03-20')
|
||||
fireEvent.click(getByText(/^Apply$/))
|
||||
expect(checkboxes.map(cb => cb.checked)).toEqual([true, false, false, false])
|
||||
})
|
||||
|
||||
it('selects some assignments from start date to end of time', async () => {
|
||||
const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
|
||||
{},
|
||||
assignmentListWithDates()
|
||||
)
|
||||
changeAndBlurInput(getByLabelText('Selection start date'), '2020-03-24') // catches the unlock dates
|
||||
fireEvent.click(getByText(/^Apply$/))
|
||||
const checkboxes = getAllByLabelText(/Select assignment:/)
|
||||
expect(checkboxes.map(cb => cb.checked)).toEqual([true, false, true, false])
|
||||
})
|
||||
|
||||
it('selects some assignments from beginning of time to end date', async () => {
|
||||
const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
|
||||
{},
|
||||
assignmentListWithDates()
|
||||
)
|
||||
changeAndBlurInput(getByLabelText('Selection end date'), '2020-03-22')
|
||||
fireEvent.click(getByText(/^Apply$/))
|
||||
const checkboxes = getAllByLabelText(/Select assignment:/)
|
||||
expect(checkboxes.map(cb => cb.checked)).toEqual([true, true, false, false])
|
||||
})
|
||||
|
||||
it('checks unlock date for selection', async () => {
|
||||
const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
|
||||
{},
|
||||
assignmentListWithDates()
|
||||
)
|
||||
changeAndBlurInput(getByLabelText('Selection start date'), '2020-03-29')
|
||||
changeAndBlurInput(getByLabelText('Selection end date'), '2020-03-29')
|
||||
fireEvent.click(getByText(/^Apply$/))
|
||||
const checkboxes = getAllByLabelText(/Select assignment:/)
|
||||
expect(checkboxes.map(cb => cb.checked)).toEqual([true, false, false, false])
|
||||
})
|
||||
|
||||
it('checks lock date for selection', async () => {
|
||||
const {getByText, getByLabelText, getAllByLabelText} = await renderBulkEditAndWait(
|
||||
{},
|
||||
assignmentListWithDates()
|
||||
)
|
||||
changeAndBlurInput(getByLabelText('Selection start date'), '2020-04-21')
|
||||
changeAndBlurInput(getByLabelText('Selection end date'), '2020-04-21')
|
||||
fireEvent.click(getByText(/^Apply$/))
|
||||
const checkboxes = getAllByLabelText(/Select assignment:/)
|
||||
expect(checkboxes.map(cb => cb.checked)).toEqual([true, false, false, false])
|
||||
})
|
||||
|
||||
it('shows an error and disables apply if end date is before start date', async () => {
|
||||
const {getByText, getByLabelText, getAllByText} = await renderBulkEditAndWait(
|
||||
{},
|
||||
assignmentListWithDates()
|
||||
)
|
||||
changeAndBlurInput(getByLabelText('Selection start date'), '2020-05-15')
|
||||
changeAndBlurInput(getByLabelText('Selection end date'), '2020-05-14')
|
||||
expect(
|
||||
getAllByText('The end date must be after the start date').length
|
||||
).toBeGreaterThanOrEqual(1)
|
||||
expect(getByText(/^Apply$/).closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('batch edit dialog', () => {
|
||||
async function renderOpenBatchEditDialog(selectAssignments = [0]) {
|
||||
const result = await renderBulkEditAndWait()
|
||||
|
@ -848,15 +1026,4 @@ describe('Assignment Bulk Edit Dates', () => {
|
|||
expect(getByText('Ok').closest('button').disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('errors', () => {
|
||||
it('dislays error message if lock-at date is before due date', async () => {})
|
||||
it('dislays error message if unlock-at date is after due date', async () => {})
|
||||
it('dislays error message if any date is after course-end date', async () => {})
|
||||
it('dislays error message if any date is before course-start date', async () => {})
|
||||
it('dislays error message if any date is before course-term start date', async () => {})
|
||||
it('dislays error message if any date is after course-term end date', async () => {})
|
||||
it('dislays error message if any date is before user-role term access from', async () => {})
|
||||
it('dislays error message if any date is after user-role term access until', async () => {})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue