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:
parent
d48344a5e1
commit
1d86c83844
|
@ -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),
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()}
|
||||
</>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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}`
|
||||
}
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue