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:
jonw 2020-05-11 14:47:10 -06:00 committed by Jon Willesen
parent 62583340bd
commit ab9316b4fe
3 changed files with 313 additions and 15 deletions

View File

@ -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()}
</>
)

View File

@ -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>
)
}

View File

@ -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 () => {})
})
})