A2: implement delete button

closes ADMIN-2233

test plan:
- click the delete button which will open a confirmation dialog that
  roughly matches the design
- a11y for the dialog should make sense
- the close button should dismiss the dialog with no effect
- the cancel button should dismiss the dialog with no effect
- the delete button should start a delete operation
- when the delete operation completes, you should be redirected to the
  assignment index screen with a message indicating the assignment has
  been deleted
- if your user does not have permission to delete the assignment, or
  some other error occurs, then the in-development error screen should
  be shown

Change-Id: I0c3304360d3c389296bd0910d02d8215bdb0ac9e
Reviewed-on: https://gerrit.instructure.com/177754
Reviewed-by: Carl Kibler <ckibler@instructure.com>
Tested-by: Jenkins
QA-Review: Carl Kibler <ckibler@instructure.com>
Product-Review: Jon Willesen <jonw+gerrit@instructure.com>
This commit is contained in:
Jon Willesen 2019-01-14 10:33:05 -07:00 committed by Jon Willesen
parent 57b7d0ca0e
commit d82b5fafe6
13 changed files with 404 additions and 30 deletions

View File

@ -0,0 +1,127 @@
/*
* 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 {bool, func, string} from 'prop-types'
import Button from '@instructure/ui-buttons/lib/components/Button'
import CloseButton from '@instructure/ui-buttons/lib/components/CloseButton'
import Heading from '@instructure/ui-elements/lib/components/Heading'
import Mask from '@instructure/ui-overlays/lib/components/Mask'
import Modal, {
ModalHeader,
ModalBody,
ModalFooter
} from '@instructure/ui-overlays/lib/components/Modal'
import Spinner from '@instructure/ui-elements/lib/components/Spinner'
import Text from '@instructure/ui-elements/lib/components/Text'
import View from '@instructure/ui-layout/lib/components/View'
export default class ConfirmDialog extends React.Component {
static propTypes = {
open: bool,
working: bool,
modalLabel: string, // defaults to heading
heading: string,
message: string.isRequired,
confirmLabel: string.isRequired,
cancelLabel: string.isRequired,
closeLabel: string.isRequired,
spinnerLabel: string.isRequired,
onClose: func,
onConfirm: func,
onCancel: func
}
static defaultProps = {
open: false,
heading: '',
onClose: () => {},
onConfirm: () => {},
onCancel: () => {}
}
setTestIdCloseButton(buttonElt) {
if (!buttonElt) return
buttonElt.setAttribute('data-testid', 'confirm-dialog-close-button')
}
setTestIdCancelButton(buttonElt) {
if (!buttonElt) return
buttonElt.setAttribute('data-testid', 'confirm-dialog-cancel-button')
}
setTestIdConfirmButton(buttonElt) {
if (!buttonElt) return
buttonElt.setAttribute('data-testid', 'confirm-dialog-confirm-button')
}
modalLabel() {
return this.props.modalLabel ? this.props.modalLabel : this.props.heading
}
render() {
return (
<Modal label={this.modalLabel()} open={this.props.open}>
<ModalHeader>
<Heading level="h2">{this.props.heading}</Heading>
<CloseButton
placement="end"
onClick={this.props.onClose}
buttonRef={this.setTestIdCloseButton}
disabled={this.props.working}
>
{this.props.closeLabel}
</CloseButton>
</ModalHeader>
<ModalBody padding="0">
<View as="div" padding="medium" style={{position: 'relative'}}>
<Text size="large">{this.props.message}</Text>
{this.props.working ? (
<Mask>
<Spinner size="small" title={this.props.spinnerLabel} />
</Mask>
) : null}
</View>
</ModalBody>
<ModalFooter>
<Button
onClick={this.props.onCancel}
margin="0 x-small 0 0"
disabled={this.props.working}
buttonRef={this.setTestIdCancelButton}
>
{this.props.cancelLabel}
</Button>
<Button
variant="danger"
onClick={this.props.onConfirm}
margin="0 x-small 0 0"
disabled={this.props.working}
buttonRef={this.setTestIdConfirmButton}
>
{this.props.confirmLabel}
</Button>
</ModalFooter>
</Modal>
)
}
}

View File

@ -38,12 +38,14 @@ export default class Header extends React.Component {
static propTypes = {
assignment: TeacherAssignmentShape.isRequired,
onUnsubmittedClick: func,
onPublishChange: func
onPublishChange: func,
onDelete: func
}
static defaultProps = {
onUnsubmittedClick: () => {},
onPublishChange: () => {}
onPublishChange: () => {},
onDelete: () => {}
}
renderIcon() {

View File

@ -18,11 +18,15 @@
import React from 'react'
import {string} from 'prop-types'
import I18n from 'i18n!assignments_2'
import ScreenReaderContent from '@instructure/ui-a11y/lib/components/ScreenReaderContent'
import {queryAssignment, setWorkflow} from '../api'
import Header from './Header'
import ContentTabs from './ContentTabs'
import ConfirmDialog from './ConfirmDialog'
import MessageStudentsWho from './MessageStudentsWho'
import TeacherViewContext, {TeacherViewContextDefaults} from './TeacherViewContext'
@ -36,6 +40,8 @@ export default class TeacherView extends React.Component {
this.state = {
messageStudentsWhoOpen: false,
assignment: {},
confirmDelete: false,
deletingNow: false,
loading: true,
errors: []
}
@ -67,10 +73,10 @@ export default class TeacherView extends React.Component {
return {assignment: {...state.assignment, ...updates}}
}
async setWorkflowApiCall(newAssignmentState) {
async setWorkflowApiCall(assignment, newAssignmentState) {
const errors = []
try {
const {graphqlErrors} = await setWorkflow(this.state.assignment, newAssignmentState)
const {errors: graphqlErrors} = await setWorkflow(assignment, newAssignmentState)
if (graphqlErrors) errors.push(...graphqlErrors)
} catch (error) {
errors.push(error)
@ -82,8 +88,8 @@ export default class TeacherView extends React.Component {
const oldAssignmentState = this.state.assignment.state
// be optimistic
this.setState(state => this.assignmentStateUpdate(state, {state: newAssignmentState}))
const errors = await this.setWorkflowApiCall(newAssignmentState)
this.setState(state => this.assignmentStateUpdate(state, newAssignmentState))
const errors = await this.setWorkflowApiCall(this.state.assignment, newAssignmentState)
if (errors.length > 0) {
this.setState(state => ({
errors,
@ -100,6 +106,35 @@ export default class TeacherView extends React.Component {
this.setState({messageStudentsWhoOpen: false})
}
handleDeleteButtonPressed = () => {
this.setState({confirmDelete: true})
}
handleCancelDelete = () => {
this.setState({confirmDelete: false})
}
handleReallyDelete = async () => {
this.setState({deletingNow: true})
const errors = await this.setWorkflowApiCall(this.state.assignment, 'deleted')
if (errors.length === 0) {
this.handleDeleteSuccess()
} else {
this.handleDeleteError(errors)
}
}
handleDeleteSuccess = () => {
// reloading a deleted assignment has the effect of redirecting to the
// assignments index page with a flash message indicating the assignment
// has been deleted.
window.location.reload()
}
handleDeleteError = errors => {
this.setState({errors, confirmDelete: false, deletingNow: false})
}
renderErrors() {
return <pre>Error: {JSON.stringify(this.state.errors, null, 2)}</pre>
}
@ -108,6 +143,25 @@ export default class TeacherView extends React.Component {
return <div>Loading...</div>
}
renderConfirmDialog() {
return (
<ConfirmDialog
open={this.state.confirmDelete}
working={this.state.deletingNow}
modalLabel={I18n.t('confirm delete')}
heading={I18n.t('Delete')}
message={I18n.t('Are you sure you want to delete this assignment?')}
confirmLabel={I18n.t('Delete')}
cancelLabel={I18n.t('Cancel')}
closeLabel={I18n.t('close')}
spinnerLabel={I18n.t('deleting assignment')}
onClose={this.handleCancelDelete}
onCancel={this.handleCancelDelete}
onConfirm={this.handleReallyDelete}
/>
)
}
render() {
if (this.state.loading) return this.renderLoading()
if (this.state.errors.length > 0) return this.renderErrors()
@ -115,6 +169,7 @@ export default class TeacherView extends React.Component {
return (
<TeacherViewContext.Provider value={this.contextValue}>
<div>
{this.renderConfirmDialog()}
<ScreenReaderContent>
<h1>{assignment.name}</h1>
</ScreenReaderContent>
@ -122,6 +177,7 @@ export default class TeacherView extends React.Component {
assignment={assignment}
onUnsubmittedClick={this.handleUnsubmittedClick}
onPublishChange={this.handlePublishChange}
onDelete={this.handleDeleteButtonPressed}
/>
<ContentTabs assignment={assignment} />
<MessageStudentsWho

View File

@ -44,12 +44,14 @@ export default class Toolbox extends React.Component {
static propTypes = {
assignment: TeacherAssignmentShape.isRequired,
onUnsubmittedClick: func,
onPublishChange: func
onPublishChange: func,
onDelete: func
}
static defaultProps = {
onUnsubmittedClick: () => {},
onPublishChange: () => {}
onPublishChange: () => {},
onDelete: () => {}
}
submissions() {
@ -82,8 +84,8 @@ export default class Toolbox extends React.Component {
renderDelete() {
return (
<Button margin="0 0 0 x-small" icon={<IconTrash />}>
<ScreenReaderContent>{I18n.t('Delete')}</ScreenReaderContent>
<Button margin="0 0 0 x-small" icon={<IconTrash />} onClick={this.props.onDelete}>
<ScreenReaderContent>{I18n.t('delete assignment')}</ScreenReaderContent>
</Button>
)
}

View File

@ -0,0 +1,66 @@
/*
* 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 'react-testing-library'
import ConfirmDialog from '../ConfirmDialog'
function renderConfirmDialog(overrideProps = {}) {
const props = {
open: true,
working: false,
heading: 'the thing',
message: 'do you want to do the thing?',
confirmLabel: 'do the thing',
cancelLabel: 'refrain from doing the thing',
closeLabel: 'close the dialog',
spinnerLabel: 'doing the thing',
...overrideProps
}
return render(<ConfirmDialog {...props} />)
}
it('triggers close', () => {
const onClose = jest.fn()
const {getByTestId} = renderConfirmDialog({onClose})
fireEvent.click(getByTestId('confirm-dialog-close-button'))
expect(onClose).toHaveBeenCalled()
})
it('triggers cancel', () => {
const onCancel = jest.fn()
const {getByTestId} = renderConfirmDialog({onCancel})
fireEvent.click(getByTestId('confirm-dialog-cancel-button'))
expect(onCancel).toHaveBeenCalled()
})
it('triggers confirm', () => {
const onConfirm = jest.fn()
const {getByTestId} = renderConfirmDialog({onConfirm})
fireEvent.click(getByTestId('confirm-dialog-confirm-button'))
expect(onConfirm).toHaveBeenCalled()
})
it('shows the spinner and disabled buttons when working', () => {
const {getByText, getByTestId} = renderConfirmDialog({working: true})
expect(getByText('doing the thing')).toBeInTheDocument()
expect(getByTestId('confirm-dialog-close-button').getAttribute('disabled')).toBe('')
expect(getByTestId('confirm-dialog-cancel-button').getAttribute('disabled')).toBe('')
expect(getByTestId('confirm-dialog-confirm-button').getAttribute('disabled')).toBe('')
})

View File

@ -16,21 +16,13 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import {render, fireEvent, wait, waitForElement} from 'react-testing-library'
import {fireEvent, waitForElement} from 'react-testing-library'
import {mockAssignment, findInputForLabel} from '../../test-utils'
import TeacherView from '../TeacherView'
import {queryAssignment, setWorkflow} from '../../api'
import {setWorkflow} from '../../api'
import {renderTeacherView} from './integration/integration-utils'
jest.mock('../../api')
async function renderTeacherView(assignment = mockAssignment()) {
queryAssignment.mockReturnValueOnce({data: {assignment}})
const result = render(<TeacherView assignmentLid={assignment.lid} />)
await wait() // wait a tick for the api promise to resolve
return result
}
it('shows the message students who dialog when the unsubmitted button is clicked', async () => {
const {getByText, getByTestId} = await renderTeacherView()
fireEvent.click(getByText(/unsubmitted/i))

View File

@ -0,0 +1,82 @@
/*
* 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 {fireEvent, wait, waitForElement} from 'react-testing-library'
import {mockAssignment, waitForNoElement} from '../../../test-utils'
import {renderTeacherView} from './integration-utils'
import {setWorkflow} from '../../../api'
jest.mock('../../../api')
async function openDeleteDialog(assignment = mockAssignment()) {
const fns = await renderTeacherView(assignment)
const openDeleteButton = await waitForElement(() => fns.getByText('delete assignment'))
fireEvent.click(openDeleteButton)
return fns
}
afterEach(() => {
jest.restoreAllMocks()
})
it('allows close', async () => {
const {getByTestId} = await openDeleteDialog()
const closeButton = await waitForElement(() => getByTestId('confirm-dialog-close-button'))
fireEvent.click(closeButton)
await waitForNoElement(() => getByTestId('confirm-dialog-close-button'))
expect(setWorkflow).not.toHaveBeenCalled()
})
it('allows cancel', async () => {
const {getByTestId} = await openDeleteDialog()
const cancelButton = await waitForElement(() => getByTestId('confirm-dialog-cancel-button'))
fireEvent.click(cancelButton)
await waitForNoElement(() => getByTestId('confirm-dialog-cancel-button'))
expect(setWorkflow).not.toHaveBeenCalled()
})
it('deletes the assignment and reloads', async () => {
const reloadSpy = jest.spyOn(window.location, 'reload')
setWorkflow.mockReturnValueOnce({data: {}})
const assignment = mockAssignment()
const {getByText, getByTestId} = await openDeleteDialog(assignment)
const reallyDeleteButton = await waitForElement(() =>
getByTestId('confirm-dialog-confirm-button')
)
fireEvent.click(reallyDeleteButton)
await waitForElement(() => getByText('deleting assignment')) // the spinner
await wait(() => expect(setWorkflow).toHaveBeenCalledWith(assignment, 'deleted'))
expect(reloadSpy).toHaveBeenCalled()
})
/* eslint-disable jest/no-disabled-tests */
// errors aren't really implemented yet
it.skip('reports errors', async () => {
setWorkflow.mockReturnValueOnce({
errors: [
/* errors data structures go here */
]
})
const {getByTestId} = await openDeleteDialog()
const reallyDeleteButton = await waitForElement(() =>
getByTestId('confirm-dialog-confirm-button')
)
fireEvent.click(reallyDeleteButton)
// waitForElement(() => {getBySomething('some kind of error message alert')})
})
/* eslint-enable jest/no-disabled-tests */

View File

@ -0,0 +1,33 @@
/*
* 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, waitForElement} from 'react-testing-library'
import TeacherView from '../../TeacherView'
// api module should be mocked by the test file
import {queryAssignment} from '../../../api'
import {mockAssignment} from '../../../test-utils'
export async function renderTeacherView(assignment = mockAssignment()) {
queryAssignment.mockReturnValueOnce({data: {assignment}})
const result = render(<TeacherView assignmentLid={assignment.lid} />)
// wait for the queryAssignment promise to resolve and the view to render in response
await waitForElement(() => result.getByText(assignment.name))
return result
}

View File

@ -17,6 +17,7 @@
*/
import {TeacherViewContextDefaults} from './components/TeacherViewContext'
import {wait} from 'react-testing-library'
// because our version of jsdom doesn't support elt.closest('a') yet. Should soon.
export function closest(el, selector) {
@ -32,6 +33,24 @@ export function findInputForLabel(labelChild, container) {
return input
}
export function waitForNoElement(queryFn) {
// use wait instead of waitForElement because waitForElement doesn't seem to
// trigger the callback when elements disappear
return wait(() => {
let elt = null
try {
elt = queryFn()
} catch (e) {
// if queryFn throws, assume element can't be found and succeed
return
}
// fail if the element was found
if (elt !== null) throw new Error(`element is still present`)
// otherwise success
})
}
export function mockCourse(overrides) {
return {
lid: 'course-lid',

View File

@ -40,5 +40,5 @@ if (process.env.DEPRECATION_SENTRY_DSN) {
// set up mocks for native APIs
if (!('MutationObserver' in window)) {
Object.defineProperty(window, 'MutationObserver', { value: require('mutation-observer') })
Object.defineProperty(window, 'MutationObserver', { value: require('@sheerun/mutationobserver-shim') })
}

View File

@ -112,6 +112,7 @@
},
"devDependencies": {
"@sentry/webpack-plugin": "^1.5.2",
"@sheerun/mutationobserver-shim": "0.3.2",
"@yarnpkg/lockfile": "^1.0.2",
"axe-core": "~2.1.7",
"babel-cli": "^6",
@ -193,7 +194,6 @@
"merge-stream": "^1",
"mockdate": "^2.0.2",
"moxios": "^0.4",
"mutation-observer": "^1.0.3",
"nyc": "^13",
"prettier": "^1",
"qunitjs": "^1.14.0",

View File

@ -38,5 +38,5 @@ document.documentElement.setAttribute('dir', 'ltr');
// set up mocks for native APIs
if (!('MutationObserver' in window)) {
Object.defineProperty(window, 'MutationObserver', { value: require('mutation-observer') });
Object.defineProperty(window, 'MutationObserver', { value: require('@sheerun/mutationobserver-shim') });
}

View File

@ -1001,7 +1001,7 @@
dependencies:
"@sentry/cli" "^1.35.5"
"@sheerun/mutationobserver-shim@^0.3.2":
"@sheerun/mutationobserver-shim@0.3.2", "@sheerun/mutationobserver-shim@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz#8013f2af54a2b7d735f71560ff360d3a8176a87b"
integrity sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q==
@ -12483,11 +12483,6 @@ multipipe@^0.1.2:
dependencies:
duplexer2 "0.0.2"
mutation-observer@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/mutation-observer/-/mutation-observer-1.0.3.tgz#42e9222b101bca82e5ba9d5a7acf4a14c0f263d0"
integrity sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA==
mute-stream@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0"