save new assignment dates on bulk edit page

note this is very rudimentary and doesn't have the niceties of a
progress bar or detailed error reporting

closes LA-872
flag=assignment_bulk_edit

test plan:
- the feature flag should be a root account flag now
- go to the assignments bulk edit page
- edit various dates
- click "Save"
- once the background job completes, refresh and check that the new
  dates have actually been saved

Change-Id: I795f60a3bc5a58c16d0353bf0d18801914b18e45
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/232298
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
QA-Review: Anju Reddy <areddy@instructure.com>
Product-Review: Jon Willesen <jonw+gerrit@instructure.com>
This commit is contained in:
jonw 2020-03-27 13:08:23 -06:00 committed by Jon Willesen
parent d48344a5e1
commit 1d86c83844
9 changed files with 516 additions and 63 deletions

View File

@ -172,7 +172,7 @@ class ApplicationController < ActionController::Base
show_feedback_link: show_feedback_link?
},
FEATURES: {
assignment_bulk_edit: Account.site_admin.feature_enabled?(:assignment_bulk_edit),
assignment_bulk_edit: @domain_root_account&.feature_enabled?(:assignment_bulk_edit),
la_620_old_rce_init_fix: Account.site_admin.feature_enabled?(:la_620_old_rce_init_fix),
cc_in_rce_video_tray: Account.site_admin.feature_enabled?(:cc_in_rce_video_tray),
featured_help_links: Account.site_admin.feature_enabled?(:featured_help_links),

View File

@ -210,11 +210,11 @@ export default class IndexMenu extends React.Component {
id="requestBulkEditMenuItem"
className="requestBulkEditMenuItem"
role="button"
title={I18n.t('Bulk Edit')}
title={I18n.t('Edit Dates')}
onClick={this.props.requestBulkEdit}
>
<i className="icon-edit" />
{I18n.t('Bulk Edit')}
{I18n.t('Edit Dates')}
</a>
</li>
)}

View File

@ -17,28 +17,66 @@
*/
import React from 'react'
import {func, instanceOf, string} from 'prop-types'
import {bool, func, instanceOf, string} from 'prop-types'
import tz from 'timezone'
import moment from 'moment-timezone'
import {DateTime} from '@instructure/ui-i18n'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import CanvasDateInput from 'jsx/shared/components/CanvasDateInput'
BulkDateInput.propTypes = {
label: string.isRequired,
selectedDate: instanceOf(Date),
onSelectedDateChange: func.isRequired
onSelectedDateChange: func.isRequired,
timezone: string,
fancyMidnight: bool
}
export default function BulkDateInput({label, selectedDate, onSelectedDateChange}) {
BulkDateInput.defaultProps = {
timezone: null,
fancyMidnight: false
}
export default function BulkDateInput({
label,
selectedDate,
onSelectedDateChange,
timezone,
fancyMidnight
}) {
// do this here so tests can modify ENV.TIMEZONE
timezone = timezone || ENV?.TIMEZONE || DateTime.browserTimeZone()
function formatDate(date) {
return tz.format(date, 'date.formats.medium_with_weekday')
}
function handleSelectedDateChange(newDate) {
if (!newDate) {
onSelectedDateChange(null)
} else if (selectedDate) {
// preserve the existing selected time by adding it to the new date
const selectedMoment = moment.tz(selectedDate, timezone)
const timeOfDayMs = selectedMoment.diff(selectedMoment.clone().startOf('day'))
const newMoment = moment.tz(newDate, timezone)
newMoment.add(timeOfDayMs, 'ms')
onSelectedDateChange(newMoment.toDate())
} else {
// assign a default time to the new date
const newMoment = moment.tz(newDate, timezone)
if (fancyMidnight) newMoment.endOf('day')
else newMoment.startOf('day')
onSelectedDateChange(newMoment.toDate())
}
}
return (
<CanvasDateInput
renderLabel={<ScreenReaderContent>{label}</ScreenReaderContent>}
selectedDate={selectedDate}
formatDate={formatDate}
onSelectedDateChange={onSelectedDateChange}
onSelectedDateChange={handleSelectedDateChange}
timezone={timezone}
/>
)
}

View File

@ -23,9 +23,12 @@ import produce from 'immer'
import {Alert} from '@instructure/ui-alerts'
import {Button} from '@instructure/ui-buttons'
import {Flex} from '@instructure/ui-flex'
import {Spinner} from '@instructure/ui-spinner'
import LoadingIndicator from 'jsx/shared/LoadingIndicator'
import useFetchApi from 'jsx/shared/effects/useFetchApi'
import BulkEditTable from './BulkEditTable'
import useSaveAssignments from './hooks/useSaveAssignments'
import {originalDateField} from './utils'
BulkEdit.propTypes = {
courseId: string.isRequired,
@ -36,6 +39,9 @@ export default function BulkEdit({courseId, onCancel}) {
const [assignments, setAssignments] = useState([])
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const {saveAssignments, isSavingAssignments, savingAssignmentsErrors} = useSaveAssignments(
courseId
)
useFetchApi({
success: setAssignments,
@ -49,10 +55,16 @@ export default function BulkEdit({courseId, onCancel}) {
}
})
function handleSave() {
saveAssignments(assignments)
}
function updateAssignment({dateKey, newDate, assignmentId, overrideId, base}) {
const nextAssignments = produce(assignments, draftAssignments => {
const assignment = draftAssignments.find(a => a.id === assignmentId)
const override = assignment.all_dates.find(o => (base ? o.base : o.id === overrideId))
const originalField = originalDateField(dateKey)
if (!override.hasOwnProperty(originalField)) override[originalField] = override[dateKey]
override[dateKey] = newDate ? newDate.toISOString() : null
})
setAssignments(nextAssignments)
@ -65,26 +77,46 @@ export default function BulkEdit({courseId, onCancel}) {
<h2>{I18n.t('Edit Dates')}</h2>
</Flex.Item>
<Flex.Item>
<Button onClick={onCancel}>{I18n.t('Cancel')}</Button>
<Button margin="0 0 0 small" onClick={onCancel}>
{I18n.t('Cancel')}
</Button>
<Button
margin="0 0 0 small"
variant="primary"
disabled={isSavingAssignments}
onClick={handleSave}
>
{I18n.t('Save')}
</Button>
{isSavingAssignments && <Spinner size="small" renderTitle={I18n.t('Saving Dates')} />}
</Flex.Item>
</Flex>
)
}
function renderError() {
function renderFetchError() {
return (
<Alert variant="error">{I18n.t('There was an error retrieving assignment dates.')}</Alert>
)
}
function renderSaveError() {
if (savingAssignmentsErrors.length) {
return (
<Alert variant="error">{I18n.t('There was an error saving the assignment dates.')}</Alert>
)
}
}
function renderBody() {
if (loading) return <LoadingIndicator />
if (error) return renderError()
if (error) return renderFetchError()
return <BulkEditTable assignments={assignments} updateAssignmentDate={updateAssignment} />
}
return (
<>
{renderSaveError()}
{renderHeader()}
{renderBody()}
</>

View File

@ -22,16 +22,25 @@ import {arrayOf, func} from 'prop-types'
import tz from 'timezone'
import {Table} from '@instructure/ui-table'
import {Responsive} from '@instructure/ui-layout'
import {Text} from '@instructure/ui-text'
import {Tooltip} from '@instructure/ui-tooltip'
import {View} from '@instructure/ui-view'
import {IconAssignmentLine, IconMiniArrowEndLine} from '@instructure/ui-icons'
import BulkDateInput from './BulkDateInput'
import {AssignmentShape} from './BulkAssignmentShape'
const DATE_INPUT_LABELS = {
due_at: I18n.t('Due At'),
unlock_at: I18n.t('Available From'),
lock_at: I18n.t('Available Until')
const DATE_INPUT_META = {
due_at: {
label: I18n.t('Due At'),
fancyMidnight: true
},
unlock_at: {
label: I18n.t('Available From'),
fancyMidnight: false
},
lock_at: {
label: I18n.t('Available Until'),
fancyMidnight: true
}
}
BulkEditTable.propTypes = {
@ -50,12 +59,14 @@ BulkEditTable.propTypes = {
}
export default function BulkEditTable({assignments, updateAssignmentDate}) {
const DATE_COLUMN_WIDTH_REMS = 14
const createUpdateAssignmentFn = opts => newDate => {
updateAssignmentDate({newDate, ...opts})
}
function renderDateInput(assignmentId, dateKey, dates, overrideId = null) {
const label = DATE_INPUT_LABELS[dateKey]
const label = DATE_INPUT_META[dateKey].label
const handleSelectedDateChange = createUpdateAssignmentFn({
dateKey,
assignmentId,
@ -67,63 +78,97 @@ export default function BulkEditTable({assignments, updateAssignmentDate}) {
label={label}
selectedDate={tz.parse(dates[dateKey])}
onSelectedDateChange={handleSelectedDateChange}
fancyMidnight={DATE_INPUT_META[dateKey].fancyMidnight}
/>
)
}
function renderAssignments() {
const rows = []
assignments.forEach(assignment => {
const baseDates = assignment.all_dates.find(dates => dates.base === true)
const overrides = assignment.all_dates.filter(dates => !dates.base)
rows.push(
function renderAssignmentTitle(assignment) {
return (
<Tooltip renderTip={assignment.name}>
<Text as="div" size="large">
<div className="ellipsis">{assignment.name}</div>
</Text>
</Tooltip>
)
}
function renderNoDefaultDates() {
// The goal here is to create a cell that spans multiple columns. You can't do that with InstUI
// yet, so we're going to fake it with a View that's as wide as the three date columns and
// depend on the cell overflow as being visible. I think that's pretty safe since that's the
// default overflow.
return (
<View as="div" minWidth={`${DATE_COLUMN_WIDTH_REMS * 3}rem`}>
<Text size="medium" fontStyle="italic">
{I18n.t('This assignment has no default dates.')}
</Text>
</View>
)
}
function renderBaseRow(assignment) {
const baseDates = assignment.all_dates.find(dates => dates.base === true)
// It's a bit repetitive this way, but Table.Row borks if it has anything but Table.Cell children.
if (baseDates) {
return (
<Table.Row key={`assignment_${assignment.id}`}>
<Table.Cell>
<Tooltip renderTip={assignment.name}>
<div className="ellipsis">
<IconAssignmentLine /> {assignment.name}
</div>
</Tooltip>
</Table.Cell>
<Table.Cell>{renderAssignmentTitle(assignment)}</Table.Cell>
<Table.Cell>{renderDateInput(assignment.id, 'due_at', baseDates)}</Table.Cell>
<Table.Cell>{renderDateInput(assignment.id, 'unlock_at', baseDates)}</Table.Cell>
<Table.Cell>{renderDateInput(assignment.id, 'lock_at', baseDates)}</Table.Cell>
</Table.Row>
)
rows.push(
...overrides.map(override => {
return (
<Table.Row key={`override_${override.id}`}>
<Table.Cell>
<View as="div" padding="0 0 0 medium">
<Tooltip renderTip={override.title}>
<div className="ellipsis">
<IconMiniArrowEndLine /> {override.title}
</div>
</Tooltip>
</View>
</Table.Cell>
<Table.Cell>
{renderDateInput(assignment.id, 'due_at', override, override.id)}
</Table.Cell>
<Table.Cell>
{renderDateInput(assignment.id, 'unlock_at', override, override.id)}
</Table.Cell>
<Table.Cell>
{renderDateInput(assignment.id, 'lock_at', override, override.id)}
</Table.Cell>
</Table.Row>
)
})
} else {
// Need all 3 Table.Cells or you get weird borders on this row
return (
<Table.Row key={`assignment_${assignment.id}`}>
<Table.Cell>{renderAssignmentTitle(assignment)}</Table.Cell>
<Table.Cell>{renderNoDefaultDates()}</Table.Cell>
<Table.Cell />
<Table.Cell />
</Table.Row>
)
}
}
function renderOverrideRows(assignment) {
const overrides = assignment.all_dates.filter(dates => !dates.base)
return overrides.map(override => {
return (
<Table.Row key={`override_${override.id}`}>
<Table.Cell>
<View as="div" padding="0 0 0 xx-large">
<Tooltip renderTip={override.title}>
<Text as="div" size="medium">
<div className="ellipsis">{override.title}</div>
</Text>
</Tooltip>
</View>
</Table.Cell>
<Table.Cell>{renderDateInput(assignment.id, 'due_at', override, override.id)}</Table.Cell>
<Table.Cell>
{renderDateInput(assignment.id, 'unlock_at', override, override.id)}
</Table.Cell>
<Table.Cell>
{renderDateInput(assignment.id, 'lock_at', override, override.id)}
</Table.Cell>
</Table.Row>
)
})
}
function renderAssignments() {
const rows = []
assignments.forEach(assignment => {
rows.push(renderBaseRow(assignment))
rows.push(...renderOverrideRows(assignment))
})
return rows
}
const COLUMN_WIDTH_REMS = 14
function renderTable(_props = {}, matches = []) {
const widthProp = `${COLUMN_WIDTH_REMS}rem`
const widthProp = `${DATE_COLUMN_WIDTH_REMS}rem`
const layoutProp = matches.includes('small') ? 'stacked' : 'fixed'
return (
<Table caption={I18n.t('Assignment Dates')} hover layout={layoutProp}>
@ -131,13 +176,13 @@ export default function BulkEditTable({assignments, updateAssignmentDate}) {
<Table.Row>
<Table.ColHeader id="title">{I18n.t('Title')}</Table.ColHeader>
<Table.ColHeader width={widthProp} id="due">
{DATE_INPUT_LABELS.due_at}
{DATE_INPUT_META.due_at.label}
</Table.ColHeader>
<Table.ColHeader width={widthProp} id="unlock">
{DATE_INPUT_LABELS.unlock_at}
{DATE_INPUT_META.unlock_at.label}
</Table.ColHeader>
<Table.ColHeader width={widthProp} id="lock">
{DATE_INPUT_LABELS.lock_at}
{DATE_INPUT_META.lock_at.label}
</Table.ColHeader>
</Table.Row>
</Table.Head>
@ -151,7 +196,7 @@ export default function BulkEditTable({assignments, updateAssignmentDate}) {
return (
<Responsive
match="media"
query={{small: {maxWidth: `${5 * COLUMN_WIDTH_REMS}rem`}}}
query={{small: {maxWidth: `${5 * DATE_COLUMN_WIDTH_REMS}rem`}}}
render={renderTable}
/>
)

View File

@ -18,6 +18,9 @@
import React from 'react'
import {render, fireEvent, act} from '@testing-library/react'
import timezone from 'timezone_core'
import tokyo from 'timezone/Asia/Tokyo'
import moment from 'moment-timezone'
import BulkEdit from '../BulkEdit'
async function flushPromises() {
@ -39,7 +42,7 @@ function standardAssignmentResponse() {
{
base: true,
unlock_at: '2020-03-19',
due_at: '2020-03-20',
due_at: '2020-03-20T03:00:00Z',
lock_at: '2020-03-21'
},
{
@ -73,12 +76,29 @@ async function renderBulkEditAndWait(overrides = {}, assignments = standardAssig
fetch.mockResponse(JSON.stringify(assignments))
const result = renderBulkEdit(overrides)
await flushPromises()
result.assignments = assignments
return result
}
beforeEach(() => fetch.resetMocks())
describe('Assignment Bulk Edit Dates', () => {
let oldEnv
let timezoneSnapshot
beforeEach(() => {
oldEnv = window.ENV
window.ENV = {
TIMEZONE: 'Asia/Tokyo'
}
timezoneSnapshot = timezone.snapshot()
timezone.changeZone(tokyo, 'Asia/Tokyo')
})
afterEach(() => {
window.ENV = oldEnv
timezone.restore(timezoneSnapshot)
})
it('shows a spinner while loading', async () => {
mockStandardAssignmentsResponse()
const {getByText} = renderBulkEdit()
@ -102,6 +122,14 @@ describe('Assignment Bulk Edit Dates', () => {
expect(lockAtInputs.map(i => i.value)).toEqual(['Sat Mar 21, 2020', 'Tue Mar 31, 2020', ''])
})
it('shows a message and no date default date fields if an assignment does not have default dates', async () => {
const assignments = standardAssignmentResponse()
assignments[0].all_dates.shift() // remove the base override
const {getByText, getAllByLabelText} = await renderBulkEditAndWait({}, assignments)
expect(getByText('This assignment has no default dates.')).toBeInTheDocument()
expect(getAllByLabelText('Due At')).toHaveLength(2)
})
it('modifies unlock date', async () => {
const {getByDisplayValue} = await renderBulkEditAndWait()
const assignmentUnlockInput = getByDisplayValue('Thu Mar 19, 2020')
@ -125,4 +153,197 @@ describe('Assignment Bulk Edit Dates', () => {
fireEvent.blur(nullDueDate)
expect(nullDueDate.value).toBe('Mon Jun 15, 2020')
})
describe('saving data', () => {
it('sets all dates in the set if any one date is edited', async () => {
const {assignments, getByText, getAllByLabelText} = await renderBulkEditAndWait()
const overrideDueAtInput = getAllByLabelText('Due At')[1]
const dueAtDate = '2020-04-01'
fireEvent.change(overrideDueAtInput, {target: {value: dueAtDate}})
fireEvent.click(getByText('Save'))
await flushPromises()
expect(fetch).toHaveBeenCalledWith(
'/api/v1/courses/42/assignments/bulk_update',
expect.objectContaining({
method: 'PUT'
})
)
const body = JSON.parse(fetch.mock.calls[1][1].body)
expect(body).toMatchObject([
{
id: 'assignment_1',
all_dates: [
{
due_at: moment.tz(dueAtDate, 'Asia/Tokyo').toISOString(),
unlock_at: assignments[0].all_dates[1].unlock_at,
lock_at: assignments[0].all_dates[1].lock_at
}
]
}
])
}, 10000)
it('can save multiple assignments and overrides', async () => {
const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
const dueAtDate = '2020-04-01'
const dueAtMoment = moment.tz(dueAtDate, 'Asia/Tokyo')
fireEvent.change(getAllByLabelText('Due At')[0], {target: {value: dueAtDate}})
fireEvent.change(getAllByLabelText('Due At')[1], {target: {value: dueAtDate}})
fireEvent.change(getAllByLabelText('Due At')[2], {target: {value: dueAtDate}})
fireEvent.click(getByText('Save'))
await flushPromises()
const body = JSON.parse(fetch.mock.calls[1][1].body)
expect(body).toMatchObject([
{
id: 'assignment_1',
all_dates: [
{
base: true,
due_at: dueAtMoment
.clone()
.add(12, 'hours') // time preservation
.toISOString()
},
{
id: 'override_1',
due_at: dueAtMoment.toISOString()
}
]
},
{
id: 'assignment_2',
all_dates: [
{
base: true,
due_at: dueAtMoment
.clone()
.endOf('day') // new due date
.toISOString()
}
]
}
])
}, 10000)
it('renders a spinner and disables the Save button while saving', async () => {
const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
const overrideDueAtInput = getAllByLabelText('Due At')[1]
const dueAtDate = '2020-04-01'
fireEvent.change(overrideDueAtInput, {target: {value: dueAtDate}})
const saveButton = getByText('Save').closest('button')
expect(saveButton.getAttribute('disabled')).toBe(null)
fireEvent.click(saveButton)
expect(saveButton.getAttribute('disabled')).toBe('')
expect(getByText('Saving Dates')).toBeInTheDocument()
await flushPromises()
}, 10000)
it('can clear an existing date', async () => {
const {assignments, getByText, getAllByLabelText} = await renderBulkEditAndWait()
const dueAtInput = getAllByLabelText('Due At')[0]
fireEvent.change(dueAtInput, {target: {value: ''}})
fireEvent.click(getByText('Save'))
await flushPromises()
const body = JSON.parse(fetch.mock.calls[1][1].body)
expect(body).toMatchObject([
{
id: 'assignment_1',
all_dates: [
{
base: true,
due_at: null,
unlock_at: assignments[0].all_dates[0].unlock_at,
lock_at: assignments[0].all_dates[0].lock_at
}
]
}
])
}, 10000)
it('invokes fancy midnight on new dates for due_at and lock_at', async () => {
const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
const dueAtInput = getAllByLabelText('Due At')[2]
const lockAtInput = getAllByLabelText('Available Until')[2]
const dueAtDate = '2020-04-01'
const lockAtDate = '2020-04-02'
fireEvent.change(dueAtInput, {target: {value: dueAtDate}})
fireEvent.change(lockAtInput, {target: {value: lockAtDate}})
fireEvent.click(getByText('Save'))
await flushPromises()
const body = JSON.parse(fetch.mock.calls[1][1].body)
expect(body).toMatchObject([
{
id: 'assignment_2',
all_dates: [
{
base: true,
due_at: moment
.tz(dueAtDate, 'Asia/Tokyo')
.endOf('day')
.toISOString(),
lock_at: moment
.tz(lockAtDate, 'Asia/Tokyo')
.endOf('day')
.toISOString(),
unlock_at: null
}
]
}
])
}, 10000)
it('invokes beginning of day on new dates for unlock_at', async () => {
const {getByText, getAllByLabelText} = await renderBulkEditAndWait()
const unlockAtInput = getAllByLabelText('Available From')[2]
const unlockDate = '2020-04-01'
fireEvent.change(unlockAtInput, {target: {value: unlockDate}})
fireEvent.click(getByText('Save'))
await flushPromises()
const body = JSON.parse(fetch.mock.calls[1][1].body)
expect(body).toMatchObject([
{
id: 'assignment_2',
all_dates: [
{
base: true,
unlock_at: moment
.tz(unlockDate, 'Asia/Tokyo')
.startOf('day')
.toISOString(),
due_at: null,
lock_at: null
}
]
}
])
}, 10000)
it('preserves the existing time on existing dates', async () => {
const {assignments, getByText, getAllByLabelText} = await renderBulkEditAndWait()
const dueAtInput = getAllByLabelText('Due At')[0]
const dueAtDate = '2020-04-01'
const originalDueAtMoment = moment.tz(assignments[0].all_dates[0].due_at, 'Asia/Tokyo')
const localTimeOffset = originalDueAtMoment.diff(originalDueAtMoment.clone().startOf('day'))
fireEvent.change(dueAtInput, {target: {value: dueAtDate}})
fireEvent.click(getByText('Save'))
await flushPromises()
const body = JSON.parse(fetch.mock.calls[1][1].body)
expect(body).toMatchObject([
{
id: 'assignment_1',
all_dates: [
{
base: true,
due_at: moment
.tz(dueAtDate, 'Asia/Tokyo')
.add(localTimeOffset, 'ms')
.toISOString()
}
]
}
])
}, 10000)
})
})

View File

@ -0,0 +1,96 @@
/*
* 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_use_save_assignment'
import {useCallback, useState} from 'react'
import doFetchApi from 'jsx/shared/effects/doFetchApi'
import {originalDateField} from '../utils'
const DATE_FIELDS = ['due_at', 'lock_at', 'unlock_at']
function extractEditedAssignmentsAndOverrides(assignments) {
const editedAssignments = assignments.reduce((filteredAssignments, assignment) => {
const editedOverrides = assignment.all_dates.reduce((filteredOverrides, override) => {
if (!DATE_FIELDS.some(field => override.hasOwnProperty(originalDateField(field)))) {
return filteredOverrides
}
const outputOverride = {id: override.id, base: override.base}
// have to copy all the date fields because otherwise the API gives the missing ones their default value
DATE_FIELDS.forEach(dateField => (outputOverride[dateField] = override[dateField]))
return [...filteredOverrides, outputOverride]
}, [])
if (!editedOverrides.length) return filteredAssignments
const outputAssignment = {id: assignment.id, all_dates: editedOverrides}
return [...filteredAssignments, outputAssignment]
}, [])
return editedAssignments
}
async function extractErrorMessages(err) {
if (!err.response) return [{message: err.message}]
const errors = await err.response.json()
if (errors.message) return [{message: errors.message}]
if (Array.isArray(errors)) {
return errors.map(error => {
const messages = []
if (error.errors.due_at) messages.push(error.errors.due_at.message)
if (error.errors.unlock_at) messages.push(error.errors.unlock_at.message)
if (error.errors.lock_at) messages.push(error.errors.lock_at.message)
return {messages, assignmentId: error.assignment_id, overrideId: error.assignment_override_id}
})
}
return [{message: I18n.t('An unknown error occurred')}]
}
export default function useSaveAssignments(courseId) {
const [isSavingAssignments, setIsSavingAssignments] = useState(false)
const [savingAssignmentsErrors, setSavingAssignmentsErrors] = useState([])
const saveAssignments = useCallback(
async assignments => {
const editedAssignments = extractEditedAssignmentsAndOverrides(assignments)
if (!editedAssignments.length) return
setIsSavingAssignments(true)
setSavingAssignmentsErrors([])
try {
await doFetchApi({
path: `/api/v1/courses/${courseId}/assignments/bulk_update`,
method: 'PUT',
body: editedAssignments
})
} catch (err) {
setSavingAssignmentsErrors(await extractErrorMessages(err))
} finally {
setIsSavingAssignments(false)
}
},
[courseId]
)
return {
saveAssignments,
isSavingAssignments,
setIsSavingAssignments,
savingAssignmentsErrors,
setSavingAssignmentsErrors
}
}

View File

@ -0,0 +1,21 @@
/*
* 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/>.
*/
export function originalDateField(dateField) {
return `original_${dateField}`
}

View File

@ -29,7 +29,7 @@ assignment_attempts:
description: Allow a teacher to set the number of allowed attempts on an assignment via the UI
assignment_bulk_edit:
state: hidden
applies_to: SiteAdmin
applies_to: RootAccount
display_name: Assignment Bulk Editing
description: Allow teachers to change the dates on a set of assignments in one step
bookmarking_for_enrollments_index: