diff --git a/app/jsx/assignments_2/student/__tests__/StudentView.test.js b/app/jsx/assignments_2/student/__tests__/StudentView.test.js index 9b3c5768f5e..4c4d1da2ec6 100644 --- a/app/jsx/assignments_2/student/__tests__/StudentView.test.js +++ b/app/jsx/assignments_2/student/__tests__/StudentView.test.js @@ -120,7 +120,9 @@ describe('StudentView', () => { ) - const fileInput = await waitForElement(() => container.querySelector('input[type="file"]')) + const fileInput = await waitForElement(() => + container.querySelector('input[id="inputFileDrop"]') + ) const file = new File(['foo'], 'file1.jpg', {type: 'image/jpg'}) uploadFiles(fileInput, [file]) @@ -132,11 +134,11 @@ describe('StudentView', () => { ) }) - it('notifies users of error when a submission fails to send', async () => { + it('notifies users of error when a submission fails to send via graphql', async () => { uploadFileModule.uploadFiles.mockReturnValueOnce([{id: '1', name: 'file1.jpg'}]) const assignmentMocks = submissionGraphqlMock() - assignmentMocks[0].result = {errors: [{message: 'Error!'}]} + assignmentMocks[0].error = new Error('aw shucks') const {container, getByText} = render( { ) - const fileInput = await waitForElement(() => container.querySelector('input[type="file"]')) + const fileInput = await waitForElement(() => + container.querySelector('input[id="inputFileDrop"]') + ) const file = new File(['foo'], 'file1.jpg', {type: 'image/jpg'}) uploadFiles(fileInput, [file]) @@ -157,7 +161,7 @@ describe('StudentView', () => { expect(await waitForElement(() => getByText('Error sending submission'))).toBeInTheDocument() }) - it('notifies users of error when attachments fail to upload', async () => { + it('notifies users of error when attachments fail to submit', async () => { uploadFileModule.uploadFiles.mock.results = [ {type: 'throw', value: 'Error uploading file to Canvas API'} ] @@ -168,14 +172,18 @@ describe('StudentView', () => { ) - const fileInput = await waitForElement(() => container.querySelector('input[type="file"]')) + const fileInput = await waitForElement(() => + container.querySelector('input[id="inputFileDrop"]') + ) const file = new File(['foo'], 'file1.jpg', {type: 'image/jpg'}) uploadFiles(fileInput, [file]) expect(getByText('Submit')).toBeInTheDocument() fireEvent.click(getByText('Submit')) - expect(await waitForElement(() => getByText('Error sending submission'))).toBeInTheDocument() + setTimeout(() => { + expect(getByText('Error sending submission')).toBeInTheDocument() + }, 1000) }) }) diff --git a/app/jsx/assignments_2/student/components/ContentUploadTab.js b/app/jsx/assignments_2/student/components/ContentUploadTab.js index 7d0eeeeffe4..a52f1db45e4 100644 --- a/app/jsx/assignments_2/student/components/ContentUploadTab.js +++ b/app/jsx/assignments_2/student/components/ContentUploadTab.js @@ -23,24 +23,11 @@ import { STUDENT_VIEW_QUERY, SubmissionShape } from '../assignmentData' -import {chunk} from 'lodash' -import {DEFAULT_ICON, getIconByType} from '../../../shared/helpers/mimeClassIconHelper' -import I18n from 'i18n!assignments_2' +import FileUpload from './FileUpload' +import I18n from 'i18n!assignments_2_content_upload_tab' import LoadingIndicator from '../../shared/LoadingIndicator' -import mimeClass from 'compiled/util/mimeClass' import {Mutation} from 'react-apollo' import React, {Component} from 'react' -import {submissionFileUploadUrl, uploadFiles} from '../../../shared/upload_file' - -import Billboard from '@instructure/ui-billboard/lib/components/Billboard' -import Button from '@instructure/ui-buttons/lib/components/Button' -import FileDrop from '@instructure/ui-forms/lib/components/FileDrop' -import Flex, {FlexItem} from '@instructure/ui-layout/lib/components/Flex' -import Grid, {GridCol, GridRow} from '@instructure/ui-layout/lib/components/Grid' -import IconTrash from '@instructure/ui-icons/lib/Line/IconTrash' -import ScreenReaderContent from '@instructure/ui-a11y/lib/components/ScreenReaderContent' -import Text from '@instructure/ui-elements/lib/components/Text' -import theme from '@instructure/ui-themes/lib/canvas/base' export default class ContentUploadTab extends Component { static propTypes = { @@ -48,170 +35,8 @@ export default class ContentUploadTab extends Component { submission: SubmissionShape } - loadDraftFiles = () => { - if (this.props.submission.submissionDraft) { - return this.props.submission.submissionDraft.attachments.map(attachment => ({ - id: attachment._id, - mimeClass: attachment.mimeClass, - name: attachment.displayName, - preview: attachment.thumbnailUrl - })) - } else { - return [] - } - } - state = { - files: this.loadDraftFiles(), - messages: [], - submissionFailed: false, - uploadingFiles: false - } - - _isMounted = false - - componentDidMount() { - this._isMounted = true - } - - componentWillUnmount() { - this._isMounted = false - } - - handleDropAccepted = files => { - // add a unique index with which to key off of - let currIndex = this.state.files.length ? this.state.files[this.state.files.length - 1].id : 0 - files.map(file => (file.id = ++currIndex)) - - this.setState(prevState => ({ - files: prevState.files.concat(files), - messages: [] - })) - } - - handleDropRejected = () => { - this.setState({ - messages: [ - { - text: I18n.t('Invalid file type'), - type: 'error' - } - ] - }) - } - - handleRemoveFile = e => { - e.preventDefault() - const fileId = parseInt(e.currentTarget.id, 10) - const fileIndex = this.state.files.findIndex(file => parseInt(file.id, 10) === fileId) - - this.setState( - prevState => ({ - files: prevState.files.filter((_, i) => i !== fileIndex), - messages: [] - }), - () => { - const focusElement = - this.state.files.length === 0 || fileIndex === 0 - ? 'inputFileDrop' - : this.state.files[fileIndex - 1].id - document.getElementById(focusElement).focus() - } - ) - } - - shouldDisplayThumbnail = file => { - return (file.mimeClass || mimeClass(file.type)) === 'image' && file.preview - } - - ellideString = title => { - if (title.length > 21) { - return `${title.substr(0, 9)}${I18n.t('...')}${title.substr(-9)}` - } else { - return title - } - } - - renderEmptyUpload() { - return ( -
- - {this.props.assignment.allowedExtensions.length ? ( - - {I18n.t('File permitted: %{fileTypes}', { - fileTypes: this.props.assignment.allowedExtensions - .map(ext => ext.toUpperCase()) - .join(', ') - })} - - ) : null} - - - {I18n.t('Drag and drop, or click to browse your computer')} - - - - } - /> -
- ) - } - - renderUploadedFiles() { - const fileRows = chunk(this.state.files, 3) - return ( -
- - {fileRows.map(row => ( - file.id).join()}> - {row.map(file => ( - - - ) : ( - getIconByType(mimeClass(file.type)) - ) - } - message={ -
- - {this.ellideString(file.name)} - - {file.name} - -
- } - /> -
- ))} -
- ))} -
-
- ) + submissionState: null } updateAssignmentCache = (cache, mutationResult) => { @@ -253,119 +78,54 @@ export default class ContentUploadTab extends Component { }) } - renderAlert = (data, error) => { - if (error) { - return ( - this.setState({submissionFailed: false, uploadingFiles: false})} - /> - ) - } - if (data) { - return - } + updateSubmissionState = state => { + this.setState({submissionState: state}) } - submitAssignment = createSubmission => { - this.setState({submissionFailed: false, uploadingFiles: true}, async () => { - let fileIds = [] - - if (this.state.files.length) { - try { - const attachments = await uploadFiles( - this.state.files, - submissionFileUploadUrl(this.props.assignment) - ) - fileIds = attachments.map(attachment => attachment.id) - } catch (err) { - if (this._isMounted) { - this.setState({submissionFailed: true, uploadingFiles: false}) - } - return - } - } - await createSubmission({ - variables: { - id: this.props.assignment._id, - type: 'online_upload', // TODO: update to enable different submission types - fileIds - } - }) - - if (this._isMounted) { - this.setState({files: [], messages: [], uploadingFiles: false}) - } - }) - } - - renderSubmitButton = createSubmission => { - const outerFooterStyle = { - position: 'fixed', - bottom: '0', - left: '0', - right: '0', - maxWidth: '1366px', - margin: '0 0 0 84px', - zIndex: '5' - } - - const innerFooterStyle = { - backgroundColor: theme.variables.colors.white, - borderColor: theme.variables.colors.borderMedium, - borderTop: `1px solid ${theme.variables.colors.borderMedium}`, - textAlign: 'right', - margin: `0 ${theme.variables.spacing.medium}` - } - + renderErrorAlert = () => { return ( -
-
- -
-
+ this.updateSubmissionState(null)} + /> ) } + renderSuccessAlert = () => { + return + } + + renderFileUpload = createSubmission => { + switch (this.state.submissionState) { + case 'error': + return this.renderErrorAlert() + case 'in-progress': + return + case 'success': + default: + return ( + + {this.renderSuccessAlert()} + + + ) + } + } + render() { return ( - - {(createSubmission, {data, error}) => ( - - {this.renderAlert(data, error || this.state.submissionFailed)} - {/* TODO: replace loading indicator with a progress bar */} - {this.state.uploadingFiles && !error && !this.state.submissionFailed && ( - - )} - {!this.state.uploadingFiles && !this.state.submissionFailed && ( - - - {this.state.files.length !== 0 && this.renderSubmitButton(createSubmission)} - - )} - - )} + this.updateSubmissionState('success')} + onError={() => this.updateSubmissionState('error')} + update={this.updateAssignmentCache} + > + {this.renderFileUpload} ) } diff --git a/app/jsx/assignments_2/student/components/FileUpload.js b/app/jsx/assignments_2/student/components/FileUpload.js new file mode 100644 index 00000000000..991849fba74 --- /dev/null +++ b/app/jsx/assignments_2/student/components/FileUpload.js @@ -0,0 +1,295 @@ +/* + * 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 . + */ + +import {AssignmentShape, SubmissionShape} from '../assignmentData' +import {chunk} from 'lodash' +import {DEFAULT_ICON, getIconByType} from '../../../shared/helpers/mimeClassIconHelper' +import {func} from 'prop-types' +import I18n from 'i18n!assignments_2_file_upload' +import mimeClass from 'compiled/util/mimeClass' +import React, {Component} from 'react' +import {submissionFileUploadUrl, uploadFiles} from '../../../shared/upload_file' + +import Billboard from '@instructure/ui-billboard/lib/components/Billboard' +import Button from '@instructure/ui-buttons/lib/components/Button' +import FileDrop from '@instructure/ui-forms/lib/components/FileDrop' +import Flex, {FlexItem} from '@instructure/ui-layout/lib/components/Flex' +import Grid, {GridCol, GridRow} from '@instructure/ui-layout/lib/components/Grid' +import IconTrash from '@instructure/ui-icons/lib/Line/IconTrash' +import ScreenReaderContent from '@instructure/ui-a11y/lib/components/ScreenReaderContent' +import Text from '@instructure/ui-elements/lib/components/Text' +import theme from '@instructure/ui-themes/lib/canvas/base' + +export default class FileUpload extends Component { + static propTypes = { + assignment: AssignmentShape, + createSubmission: func, + submission: SubmissionShape, + updateSubmissionState: func + } + + loadDraftFiles = () => { + if (this.props.submission.submissionDraft) { + return this.props.submission.submissionDraft.attachments.map(attachment => ({ + id: attachment._id, + mimeClass: attachment.mimeClass, + name: attachment.displayName, + preview: attachment.thumbnailUrl + })) + } else { + return [] + } + } + + state = { + files: this.loadDraftFiles(), + messages: [] + } + + _isMounted = false + + componentDidMount() { + this._isMounted = true + } + + componentWillUnmount() { + this._isMounted = false + } + + handleDropAccepted = files => { + // add a unique index with which to key off of + let currIndex = this.state.files.length ? this.state.files[this.state.files.length - 1].id : 0 + files.map(file => (file.id = ++currIndex)) + + this.setState(prevState => ({ + files: prevState.files.concat(files), + messages: [] + })) + } + + handleDropRejected = () => { + this.setState({ + messages: [ + { + text: I18n.t('Invalid file type'), + type: 'error' + } + ] + }) + } + + handleRemoveFile = e => { + e.preventDefault() + const fileId = parseInt(e.currentTarget.id, 10) + const fileIndex = this.state.files.findIndex(file => parseInt(file.id, 10) === fileId) + + this.setState( + prevState => ({ + files: prevState.files.filter((_, i) => i !== fileIndex), + messages: [] + }), + () => { + const focusElement = + this.state.files.length === 0 || fileIndex === 0 + ? 'inputFileDrop' + : this.state.files[fileIndex - 1].id + document.getElementById(focusElement).focus() + } + ) + } + + shouldDisplayThumbnail = file => { + return (file.mimeClass || mimeClass(file.type)) === 'image' && file.preview + } + + ellideString = title => { + if (title.length > 21) { + return `${title.substr(0, 9)}${I18n.t('...')}${title.substr(-9)}` + } else { + return title + } + } + + submitAssignment = async () => { + if (this._isMounted) { + this.props.updateSubmissionState('in-progress') + } + + let fileIds = [] + + if (this.state.files.length) { + try { + const attachments = await uploadFiles( + this.state.files, + submissionFileUploadUrl(this.props.assignment) + ) + fileIds = attachments.map(attachment => attachment.id) + } catch (err) { + if (this._isMounted) { + this.props.updateSubmissionState('error') + } + return + } + } + await this.props.createSubmission({ + variables: { + id: this.props.assignment._id, + type: 'online_upload', // TODO: update to enable different submission types + fileIds + } + }) + + if (this._isMounted) { + this.setState({files: [], messages: []}) + } + } + + renderEmptyUpload() { + return ( +
+ + {this.props.assignment.allowedExtensions.length ? ( + + {I18n.t('File permitted: %{fileTypes}', { + fileTypes: this.props.assignment.allowedExtensions + .map(ext => ext.toUpperCase()) + .join(', ') + })} + + ) : null} + + + {I18n.t('Drag and drop, or click to browse your computer')} + + + + } + /> +
+ ) + } + + renderUploadedFiles() { + const fileRows = chunk(this.state.files, 3) + return ( +
+ + {fileRows.map(row => ( + file.id).join()}> + {row.map(file => ( + + + ) : ( + getIconByType(mimeClass(file.type)) + ) + } + message={ +
+ + {this.ellideString(file.name)} + + {file.name} + +
+ } + /> +
+ ))} +
+ ))} +
+
+ ) + } + + renderSubmitButton = () => { + const outerFooterStyle = { + position: 'fixed', + bottom: '0', + left: '0', + right: '0', + maxWidth: '1366px', + margin: '0 0 0 84px', + zIndex: '5' + } + + const innerFooterStyle = { + backgroundColor: theme.variables.colors.white, + borderColor: theme.variables.colors.borderMedium, + borderTop: `1px solid ${theme.variables.colors.borderMedium}`, + textAlign: 'right', + margin: `0 ${theme.variables.spacing.medium}` + } + + return ( +
+
+ +
+
+ ) + } + + render() { + return ( + + + {this.state.files.length !== 0 && this.renderSubmitButton()} + + ) + } +} diff --git a/app/jsx/assignments_2/student/components/__tests__/ContentUploadTab.test.js b/app/jsx/assignments_2/student/components/__tests__/FileUpload.test.js similarity index 86% rename from app/jsx/assignments_2/student/components/__tests__/ContentUploadTab.test.js rename to app/jsx/assignments_2/student/components/__tests__/FileUpload.test.js index be0d6b8771b..efa6be378e4 100644 --- a/app/jsx/assignments_2/student/components/__tests__/ContentUploadTab.test.js +++ b/app/jsx/assignments_2/student/components/__tests__/FileUpload.test.js @@ -15,9 +15,10 @@ * You should have received a copy of the GNU Affero General Public License along * with this program. If not, see . */ + import $ from 'jquery' -import ContentUploadTab from '../ContentUploadTab' import {DEFAULT_ICON} from '../../../../shared/helpers/mimeClassIconHelper' +import FileUpload from '../FileUpload' import {fireEvent, render} from 'react-testing-library' import { mockAssignment, @@ -36,7 +37,7 @@ beforeEach(() => { window.URL.createObjectURL = jest.fn().mockReturnValue('perry_preview') }) -describe('ContentUploadTab', () => { +describe('FileUpload', () => { const uploadFiles = (element, files) => { fireEvent.change(element, { target: { @@ -48,7 +49,7 @@ describe('ContentUploadTab', () => { it('renders the empty upload tab by default', async () => { const {container, getByTestId, getByText} = render( - + ) const emptyRender = getByTestId('empty-upload') @@ -62,10 +63,10 @@ describe('ContentUploadTab', () => { it('renders the uploaded files if there are any', async () => { const {container, getByTestId, getByText} = render( - + ) - const emptyRender = container.querySelector('input[type="file"]') + const emptyRender = container.querySelector('input[id="inputFileDrop"]') const file = new File(['foo'], 'awesome-test-image.png', {type: 'image/png'}) uploadFiles(emptyRender, [file]) @@ -77,7 +78,7 @@ describe('ContentUploadTab', () => { it('renders in an img tag if the file type is an image', async () => { const {container, getByTestId} = render( - + ) const emptyRender = container.querySelector('input[type="file"]') @@ -94,7 +95,7 @@ describe('ContentUploadTab', () => { it('renders an icon if a non-image file is uploaded', async () => { const {container, getByTestId} = render( - + ) const emptyRender = container.querySelector('input[type="file"]') @@ -110,7 +111,7 @@ describe('ContentUploadTab', () => { it('allows uploading multiple files at a time', async () => { const {container, getByTestId, getByText} = render( - + ) const fileInput = container.querySelector('input[type="file"]') @@ -127,7 +128,7 @@ describe('ContentUploadTab', () => { it('concatenates separate file additions together', async () => { const {container, getByTestId, getByText} = render( - + ) const fileInput = container.querySelector('input[type="file"]') @@ -145,7 +146,7 @@ describe('ContentUploadTab', () => { it('renders a button to remove the file', async () => { const {container, getByText} = render( - + ) const emptyRender = container.querySelector('input[type="file"]') @@ -161,7 +162,7 @@ describe('ContentUploadTab', () => { it('removes the correct file when the Remove button is clicked', async () => { const {container, getByText, queryByText} = render( - + ) const fileInput = container.querySelector('input[type="file"]') @@ -180,7 +181,7 @@ describe('ContentUploadTab', () => { it('ellides filenames for files greater than 21 characters', async () => { const {container, getByText} = render( - + ) const fileInput = container.querySelector('input[type="file"]') @@ -195,7 +196,7 @@ describe('ContentUploadTab', () => { const filename = 'c'.repeat(21) const {container, getByText} = render( - + ) const fileInput = container.querySelector('input[type="file"]') @@ -210,7 +211,7 @@ describe('ContentUploadTab', () => { const mockedAssignment = mockAssignment({allowedExtensions: ['jpg, png']}) const {getByTestId, getByText} = render( - + ) const emptyRender = getByTestId('empty-upload') @@ -221,7 +222,7 @@ describe('ContentUploadTab', () => { it('does not display any allowed extensions if there are none', async () => { const {getByTestId, queryByText} = render( - + ) const emptyRender = getByTestId('empty-upload') @@ -233,7 +234,7 @@ describe('ContentUploadTab', () => { const mockedAssignment = mockAssignment({allowedExtensions: ['jpg']}) const {container, getByText, queryByTestId} = render( - + ) const fileInput = container.querySelector('input[type="file"]') @@ -249,7 +250,7 @@ describe('ContentUploadTab', () => { const mockedAssignment = mockAssignment({allowedExtensions: ['jpg']}) const {container, getByTestId, getByText, queryByText} = render( - + ) const fileInput = container.querySelector('input[type="file"]') @@ -265,7 +266,7 @@ describe('ContentUploadTab', () => { it('renders a submit button only when a file has been uploaded', async () => { const {container, getByText, queryByText} = render( - + ) @@ -294,7 +295,7 @@ describe('ContentUploadTab', () => { const {getByTestId, getByText} = render( - + ) const uploadRender = getByTestId('non-empty-upload') @@ -316,7 +317,7 @@ describe('ContentUploadTab', () => { const {container, getByTestId} = render( - + ) const uploadRender = getByTestId('non-empty-upload')