1
0
mirror of https://github.com/ascribe/onion.git synced 2024-12-22 09:23:13 +01:00

Merge pull request #35 from ascribe/AD-1360-show-common-upload-errors-to-user

Show more detailed upload errors and suggest solutions
This commit is contained in:
Brett Sun 2016-02-08 11:36:35 +01:00
commit 4c821bd744
20 changed files with 1132 additions and 398 deletions

View File

@ -0,0 +1,13 @@
'use strict';
import { alt } from '../alt';
class ErrorQueueActions {
constructor() {
this.generateActions(
'shiftErrorQueue'
);
}
}
export default alt.createActions(ErrorQueueActions);

View File

@ -14,19 +14,24 @@ import { getCookie } from '../../utils/fetch_api_utils';
import { getLangText } from '../../utils/lang_utils';
const { func, bool, number, object, string, arrayOf } = React.PropTypes;
let FurtherDetailsFileuploader = React.createClass({
propTypes: {
pieceId: React.PropTypes.number.isRequired,
pieceId: number.isRequired,
areAssetsDownloadable: React.PropTypes.bool,
editable: React.PropTypes.bool,
isReadyForFormSubmission: React.PropTypes.func,
label: React.PropTypes.string,
multiple: React.PropTypes.bool,
otherData: React.PropTypes.arrayOf(React.PropTypes.object),
onValidationFailed: React.PropTypes.func,
setIsUploadReady: React.PropTypes.func,
submitFile: React.PropTypes.func,
editable: bool,
label: string,
otherData: arrayOf(object),
// Props for ReactS3FineUploader
areAssetsDownloadable: bool,
isReadyForFormSubmission: func,
submitFile: func, // TODO: rename to onSubmitFile
onValidationFailed: func,
multiple: bool,
setIsUploadReady: func, //TODO: rename to setIsUploaderValidated
showErrorPrompt: bool,
validation: ReactS3FineUploader.propTypes.validation
},
@ -40,36 +45,57 @@ let FurtherDetailsFileuploader = React.createClass({
},
render() {
const {
editable,
isReadyForFormSubmission,
multiple,
onValidationFailed,
otherData,
pieceId,
setIsUploadReady,
showErrorPrompt,
submitFile,
validation } = this.props;
// Essentially there a three cases important to the fileuploader
//
// 1. there is no other_data => do not show the fileuploader at all (where otherData is now an array)
// 2. there is other_data, but user has no edit rights => show fileuploader but without action buttons
// 3. both other_data and editable are defined or true => show fileuploader with all action buttons
if (!this.props.editable && (!this.props.otherData || this.props.otherData.length === 0)) {
if (!editable && (!otherData || otherData.length === 0)) {
return null;
}
let otherDataIds = this.props.otherData ? this.props.otherData.map((data) => data.id).join() : null;
let otherDataIds = otherData ? otherData.map((data) => data.id).join() : null;
return (
<Property
name="other_data_key"
label={this.props.label}>
<ReactS3FineUploader
areAssetsDownloadable
areAssetsEditable={editable}
createBlobRoutine={{
url: ApiUrls.blob_otherdatas,
pieceId: pieceId
}}
deleteFile={{
enabled: true,
method: 'DELETE',
endpoint: AppConstants.serverUrl + 's3/delete',
customHeaders: {
'X-CSRFToken': getCookie(AppConstants.csrftoken)
}
}}
isReadyForFormSubmission={isReadyForFormSubmission}
keyRoutine={{
url: AppConstants.serverUrl + 's3/key/',
fileClass: 'otherdata',
pieceId: this.props.pieceId
pieceId: pieceId
}}
createBlobRoutine={{
url: ApiUrls.blob_otherdatas,
pieceId: this.props.pieceId
}}
validation={this.props.validation}
submitFile={this.props.submitFile}
onValidationFailed={this.props.onValidationFailed}
setIsUploadReady={this.props.setIsUploadReady}
isReadyForFormSubmission={this.props.isReadyForFormSubmission}
multiple={multiple}
onValidationFailed={onValidationFailed}
setIsUploadReady={setIsUploadReady}
session={{
endpoint: AppConstants.serverUrl + 'api/blob/otherdatas/fineuploader_session/',
customHeaders: {
@ -89,17 +115,9 @@ let FurtherDetailsFileuploader = React.createClass({
'X-CSRFToken': getCookie(AppConstants.csrftoken)
}
}}
deleteFile={{
enabled: true,
method: 'DELETE',
endpoint: AppConstants.serverUrl + 's3/delete',
customHeaders: {
'X-CSRFToken': getCookie(AppConstants.csrftoken)
}
}}
areAssetsDownloadable={this.props.areAssetsDownloadable}
areAssetsEditable={this.props.editable}
multiple={this.props.multiple} />
submitFile={submitFile}
showErrorPrompt={showErrorPrompt}
validation={validation} />
</Property>
);
}

View File

@ -8,6 +8,7 @@ import InputFineUploader from './input_fineuploader';
import FormSubmitButton from '../ascribe_buttons/form_submit_button';
import { FileStatus } from '../ascribe_uploader/react_s3_fine_uploader_utils';
import UploadButton from '../ascribe_uploader/ascribe_upload_button/upload_button';
import AscribeSpinner from '../ascribe_spinner';
@ -72,7 +73,7 @@ let RegisterPieceForm = React.createClass({
handleChangedDigitalWork(digitalWorkFile) {
if (digitalWorkFile &&
(digitalWorkFile.status === 'deleted' || digitalWorkFile.status === 'canceled')) {
(digitalWorkFile.status === FileStatus.DELETED || digitalWorkFile.status === FileStatus.CANCELED)) {
this.refs.form.refs.thumbnail_file.reset();
// Manually we need to set the ready state for `thumbnailKeyReady` back
@ -91,8 +92,8 @@ let RegisterPieceForm = React.createClass({
fineuploader.setThumbnailForFileId(
digitalWorkFile.id,
// if thumbnail was delete, we delete it from the display as well
thumbnailFile.status !== 'deleted' ? thumbnailFile.url : null
// if thumbnail was deleted, we delete it from the display as well
thumbnailFile.status !== FileStatus.DELETED ? thumbnailFile.url : null
);
},
@ -175,7 +176,8 @@ let RegisterPieceForm = React.createClass({
disabled={!isFineUploaderEditable}
enableLocalHashing={hashLocally}
uploadMethod={location.query.method}
handleChangedFile={this.handleChangedDigitalWork}/>
handleChangedFile={this.handleChangedDigitalWork}
showErrorPrompt />
</Property>
<Property
name="thumbnail_file"

View File

@ -10,48 +10,35 @@ import AppConstants from '../../constants/application_constants';
import { getCookie } from '../../utils/fetch_api_utils';
const { func, bool, shape, string, number, arrayOf } = React.PropTypes;
const { func, bool, shape, string, number, element, oneOf, oneOfType, arrayOf } = React.PropTypes;
const InputFineUploader = React.createClass({
propTypes: {
setIsUploadReady: func,
isReadyForFormSubmission: func,
submitFile: func,
fileInputElement: func,
areAssetsDownloadable: bool,
keyRoutine: shape({
url: string,
fileClass: string
}),
createBlobRoutine: shape({
url: string
}),
validation: ReactS3FineUploader.propTypes.validation,
// isFineUploaderActive is used to lock react fine uploader in case
// a user is actually not logged in already to prevent him from droping files
// before login in
isFineUploaderActive: bool,
enableLocalHashing: bool,
uploadMethod: string,
// provided by Property
disabled: bool,
onChange: func,
// A class of a file the user has to upload
// Needs to be defined both in singular as well as in plural
fileClassToUpload: shape({
singular: string,
plural: string
}),
handleChangedFile: func,
// Props for ReactS3FineUploader
areAssetsDownloadable: bool,
createBlobRoutine: ReactS3FineUploader.propTypes.createBlobRoutine,
enableLocalHashing: bool,
fileClassToUpload: ReactS3FineUploader.propTypes.fileClassToUpload,
fileInputElement: ReactS3FineUploader.propTypes.fileInputElement,
isReadyForFormSubmission: func,
keyRoutine: ReactS3FineUploader.propTypes.keyRoutine,
handleChangedFile: func, // TODO: rename to onChangedFile
submitFile: func, // TODO: rename to onSubmitFile
onValidationFailed: func,
// Provided by `Property`
onChange: React.PropTypes.func
setIsUploadReady: func, //TODO: rename to setIsUploaderValidated
setWarning: func,
showErrorPrompt: bool,
uploadMethod: oneOf(['hash', 'upload']),
validation: ReactS3FineUploader.propTypes.validation,
},
getDefaultProps() {
@ -96,19 +83,21 @@ const InputFineUploader = React.createClass({
render() {
const {
areAssetsDownloadable,
enableLocalHashing,
createBlobRoutine,
enableLocalHashing,
disabled,
fileClassToUpload,
fileInputElement,
handleChangedFile,
isFineUploaderActive,
isReadyForFormSubmission,
keyRoutine,
onValidationFailed,
setIsUploadReady,
setWarning,
showErrorPrompt,
uploadMethod,
validation,
handleChangedFile } = this.props;
validation } = this.props;
let editable = isFineUploaderActive;
// if disabled is actually set by property, we want to override
@ -130,6 +119,8 @@ const InputFineUploader = React.createClass({
isReadyForFormSubmission={isReadyForFormSubmission}
areAssetsDownloadable={areAssetsDownloadable}
areAssetsEditable={editable}
setWarning={setWarning}
showErrorPrompt={showErrorPrompt}
signature={{
endpoint: AppConstants.serverUrl + 's3/signature/',
customHeaders: {
@ -147,7 +138,7 @@ const InputFineUploader = React.createClass({
enableLocalHashing={enableLocalHashing}
uploadMethod={uploadMethod}
fileClassToUpload={fileClassToUpload}
handleChangedFile={handleChangedFile}/>
handleChangedFile={handleChangedFile} />
);
}
});

View File

@ -72,7 +72,8 @@ const Property = React.createClass({
initialValue: null,
value: null,
isFocused: false,
errors: null
errors: null,
hasWarning: false
};
},
@ -218,17 +219,20 @@ const Property = React.createClass({
this.setState({errors: null});
},
setWarning(hasWarning) {
this.setState({ hasWarning });
},
getClassName() {
if(!this.state.expanded && !this.props.checkboxLabel){
if (!this.state.expanded && !this.props.checkboxLabel) {
return 'is-hidden';
}
if(!this.props.editable){
} else if (!this.props.editable) {
return 'is-fixed';
}
if (this.state.errors){
} else if (this.state.errors) {
return 'is-error';
}
if(this.state.isFocused) {
} else if (this.state.hasWarning) {
return 'is-warning';
} else if (this.state.isFocused) {
return 'is-focused';
} else {
return '';
@ -271,6 +275,7 @@ const Property = React.createClass({
onChange: this.handleChange,
onFocus: this.handleFocus,
onBlur: this.handleBlur,
setWarning: this.setWarning,
disabled: !this.props.editable,
ref: 'input',
name: this.props.name,

View File

@ -17,7 +17,7 @@ const WHEN_ENUM = ['loggedIn', 'loggedOut'];
*
* @param {enum/string} options.when ('loggedIn' || 'loggedOut')
*/
export function AuthRedirect({to, when}) {
export function AuthRedirect({ to, when }) {
// validate `when`, must be contained in `WHEN_ENUM`.
// Throw an error otherwise.
if (WHEN_ENUM.indexOf(when) === -1) {
@ -80,8 +80,8 @@ export function ProxyHandler(...redirectFunctions) {
displayName: 'ProxyHandler',
propTypes: {
// Provided from AscribeApp
currentUser: React.PropTypes.object.isRequired,
// Provided from AscribeApp, after the routes have been initialized
currentUser: React.PropTypes.object,
whitelabel: React.PropTypes.object,
// Provided from router

View File

@ -1,29 +1,33 @@
'use strict';
import React from 'react';
import classNames from 'classnames';
import ProgressBar from 'react-bootstrap/lib/ProgressBar';
import FileDragAndDropDialog from './file_drag_and_drop_dialog';
import FileDragAndDropErrorDialog from './file_drag_and_drop_error_dialog';
import FileDragAndDropPreviewIterator from './file_drag_and_drop_preview_iterator';
import { FileStatus } from '../react_s3_fine_uploader_utils';
import { getLangText } from '../../../utils/lang_utils';
// Taken from: https://github.com/fedosejev/react-file-drag-and-drop
let FileDragAndDrop = React.createClass({
propTypes: {
className: React.PropTypes.string,
areAssetsDownloadable: React.PropTypes.bool,
areAssetsEditable: React.PropTypes.bool,
multiple: React.PropTypes.bool,
dropzoneInactive: React.PropTypes.bool,
filesToUpload: React.PropTypes.array,
onDrop: React.PropTypes.func.isRequired,
onDragOver: React.PropTypes.func,
filesToUpload: React.PropTypes.array,
handleDeleteFile: React.PropTypes.func,
handleCancelFile: React.PropTypes.func,
handlePauseFile: React.PropTypes.func,
handleResumeFile: React.PropTypes.func,
multiple: React.PropTypes.bool,
dropzoneInactive: React.PropTypes.bool,
areAssetsDownloadable: React.PropTypes.bool,
areAssetsEditable: React.PropTypes.bool,
handleRetryFiles: React.PropTypes.func,
enableLocalHashing: React.PropTypes.bool,
uploadMethod: React.PropTypes.string,
@ -34,6 +38,12 @@ let FileDragAndDrop = React.createClass({
// to -1 which is code for: aborted
handleCancelHashing: React.PropTypes.func,
showError: React.PropTypes.bool,
errorClass: React.PropTypes.shape({
name: React.PropTypes.string,
prettifiedText: React.PropTypes.string
}),
// A class of a file the user has to upload
// Needs to be defined both in singular as well as in plural
fileClassToUpload: React.PropTypes.shape({
@ -126,65 +136,93 @@ let FileDragAndDrop = React.createClass({
}
},
getErrorDialog(failedFiles) {
const { errorClass } = this.props;
return (
<FileDragAndDropErrorDialog
errorClass={errorClass}
files={failedFiles}
handleRetryFiles={this.props.handleRetryFiles} />
);
},
getPreviewIterator() {
const { areAssetsDownloadable, areAssetsEditable, filesToUpload } = this.props;
return (
<FileDragAndDropPreviewIterator
files={filesToUpload}
handleDeleteFile={this.handleDeleteFile}
handleCancelFile={this.handleCancelFile}
handlePauseFile={this.handlePauseFile}
handleResumeFile={this.handleResumeFile}
areAssetsDownloadable={areAssetsDownloadable}
areAssetsEditable={areAssetsEditable}/>
);
},
getUploadDialog() {
const { enableLocalHashing, fileClassToUpload, multiple, uploadMethod } = this.props;
return (
<FileDragAndDropDialog
multipleFiles={multiple}
onClick={this.handleOnClick}
enableLocalHashing={enableLocalHashing}
uploadMethod={uploadMethod}
fileClassToUpload={fileClassToUpload} />
);
},
render: function () {
const {
filesToUpload,
dropzoneInactive,
className,
hashingProgress,
handleCancelHashing,
multiple,
enableLocalHashing,
uploadMethod,
showError,
errorClass,
fileClassToUpload,
areAssetsDownloadable,
areAssetsEditable,
allowedExtensions } = this.props;
// has files only is true if there are files that do not have the status deleted or canceled
let hasFiles = filesToUpload.filter((file) => file.status !== 'deleted' && file.status !== 'canceled' && file.size !== -1).length > 0;
let updatedClassName = hasFiles ? 'has-files ' : '';
updatedClassName += dropzoneInactive ? 'inactive-dropzone' : 'active-dropzone';
updatedClassName += ' file-drag-and-drop';
// has files only is true if there are files that do not have the status deleted, canceled, or failed
const hasFiles = filesToUpload
.filter((file) => {
return file.status !== FileStatus.DELETED &&
file.status !== FileStatus.CANCELED &&
file.status !== FileStatus.UPLOAD_FAILED &&
file.size !== -1;
})
.length > 0;
const failedFiles = filesToUpload.filter((file) => file.status === FileStatus.UPLOAD_FAILED);
let hasError = showError && errorClass && failedFiles.length > 0;
// if !== -2: triggers a FileDragAndDrop-global spinner
if(hashingProgress !== -2) {
if (hashingProgress !== -2) {
return (
<div className={className}>
<div className="file-drag-and-drop-hashing-dialog">
<p>{getLangText('Computing hash(es)... This may take a few minutes.')}</p>
<p>
<a onClick={handleCancelHashing}> {getLangText('Cancel hashing')}</a>
</p>
<ProgressBar
now={Math.ceil(hashingProgress)}
label="%(percent)s%"
className="ascribe-progress-bar"/>
</div>
<div className="file-drag-and-drop-hashing-dialog">
<p>{getLangText('Computing hash(es)... This may take a few minutes.')}</p>
<p>
<a onClick={handleCancelHashing}> {getLangText('Cancel hashing')}</a>
</p>
<ProgressBar
now={Math.ceil(hashingProgress)}
label="%(percent)s%"
className="ascribe-progress-bar"/>
</div>
);
} else {
return (
<div
className={updatedClassName}
className={classNames('file-drag-and-drop', dropzoneInactive ? 'inactive-dropzone' : 'active-dropzone', { 'has-files': hasFiles })}
onDrag={this.handleDrop}
onDragOver={this.handleDragOver}
onDrop={this.handleDrop}>
<FileDragAndDropDialog
multipleFiles={multiple}
hasFiles={hasFiles}
onClick={this.handleOnClick}
enableLocalHashing={enableLocalHashing}
uploadMethod={uploadMethod}
fileClassToUpload={fileClassToUpload} />
<FileDragAndDropPreviewIterator
files={filesToUpload}
handleDeleteFile={this.handleDeleteFile}
handleCancelFile={this.handleCancelFile}
handlePauseFile={this.handlePauseFile}
handleResumeFile={this.handleResumeFile}
areAssetsDownloadable={areAssetsDownloadable}
areAssetsEditable={areAssetsEditable}/>
{hasError ? this.getErrorDialog(failedFiles) : this.getPreviewIterator()}
{!hasFiles && !hasError ? this.getUploadDialog() : null}
{/*
Opera doesn't trigger simulated click events
if the targeted input has `display:none` set.

View File

@ -9,7 +9,6 @@ import { getCurrentQueryParams } from '../../../utils/url_utils';
let FileDragAndDropDialog = React.createClass({
propTypes: {
hasFiles: React.PropTypes.bool,
multipleFiles: React.PropTypes.bool,
enableLocalHashing: React.PropTypes.bool,
uploadMethod: React.PropTypes.string,
@ -36,92 +35,88 @@ let FileDragAndDropDialog = React.createClass({
render() {
const {
hasFiles,
multipleFiles,
enableLocalHashing,
uploadMethod,
fileClassToUpload,
onClick } = this.props;
let dialogElement;
if (hasFiles) {
return null;
} else {
let dialogElement;
if (enableLocalHashing && !uploadMethod) {
const currentQueryParams = getCurrentQueryParams();
if (enableLocalHashing && !uploadMethod) {
const currentQueryParams = getCurrentQueryParams();
const queryParamsHash = Object.assign({}, currentQueryParams);
queryParamsHash.method = 'hash';
const queryParamsHash = Object.assign({}, currentQueryParams);
queryParamsHash.method = 'hash';
const queryParamsUpload = Object.assign({}, currentQueryParams);
queryParamsUpload.method = 'upload';
const queryParamsUpload = Object.assign({}, currentQueryParams);
queryParamsUpload.method = 'upload';
dialogElement = (
<div className="present-options">
<p className="file-drag-and-drop-dialog-title">{getLangText('Would you rather')}</p>
{/*
The frontend in live is hosted under /app,
Since `Link` is appending that base url, if its defined
by itself, we need to make sure to not set it at this point.
Otherwise it will be appended twice.
*/}
<Link
to={`/${window.location.pathname.split('/').pop()}`}
query={queryParamsHash}>
<span className="btn btn-default btn-sm">
{getLangText('Hash your work')}
</span>
</Link>
<span> or </span>
<Link
to={`/${window.location.pathname.split('/').pop()}`}
query={queryParamsUpload}>
<span className="btn btn-default btn-sm">
{getLangText('Upload and hash your work')}
</span>
</Link>
</div>
);
} else {
if (multipleFiles) {
dialogElement = [
this.getDragDialog(fileClassToUpload.plural),
<span
className="btn btn-default"
onClick={onClick}>
{getLangText('choose %s to upload', fileClassToUpload.plural)}
dialogElement = (
<div className="present-options">
<p className="file-drag-and-drop-dialog-title">{getLangText('Would you rather')}</p>
{/*
The frontend in live is hosted under /app,
Since `Link` is appending that base url, if its defined
by itself, we need to make sure to not set it at this point.
Otherwise it will be appended twice.
*/}
<Link
to={`/${window.location.pathname.split('/').pop()}`}
query={queryParamsHash}>
<span className="btn btn-default btn-sm">
{getLangText('Hash your work')}
</span>
];
} else {
const dialog = uploadMethod === 'hash' ? getLangText('choose a %s to hash', fileClassToUpload.singular)
: getLangText('choose a %s to upload', fileClassToUpload.singular);
</Link>
dialogElement = [
this.getDragDialog(fileClassToUpload.singular),
<span
className="btn btn-default"
onClick={onClick}>
{dialog}
<span> {getLangText('or')} </span>
<Link
to={`/${window.location.pathname.split('/').pop()}`}
query={queryParamsUpload}>
<span className="btn btn-default btn-sm">
{getLangText('Upload and hash your work')}
</span>
];
}
}
return (
<div className="file-drag-and-drop-dialog">
<div className="hidden-print">
{dialogElement}
</div>
{/* Hide the uploader and just show that there's been on files uploaded yet when printing */}
<p className="text-align-center visible-print">
{getLangText('No files uploaded')}
</p>
</Link>
</div>
);
} else {
if (multipleFiles) {
dialogElement = [
this.getDragDialog(fileClassToUpload.plural),
(<span
key='mutlipleFilesBtn'
className="btn btn-default"
onClick={onClick}>
{getLangText('choose %s to upload', fileClassToUpload.plural)}
</span>)
];
} else {
const dialog = uploadMethod === 'hash' ? getLangText('choose a %s to hash', fileClassToUpload.singular)
: getLangText('choose a %s to upload', fileClassToUpload.singular);
dialogElement = [
this.getDragDialog(fileClassToUpload.singular),
(<span
key='singleFileBtn'
className="btn btn-default"
onClick={onClick}>
{dialog}
</span>)
];
}
}
return (
<div className="file-drag-and-drop-dialog">
<div className="hidden-print">
{dialogElement}
</div>
{/* Hide the uploader and just show that there's been on files uploaded yet when printing */}
<p className="text-align-center visible-print">
{getLangText('No files uploaded')}
</p>
</div>
);
}
});

View File

@ -0,0 +1,86 @@
'use strict';
import React from 'react';
import classNames from 'classnames';
import { ErrorClasses } from '../../../constants/error_constants';
import { getLangText } from '../../../utils/lang_utils';
let FileDragAndDropErrorDialog = React.createClass({
propTypes: {
errorClass: React.PropTypes.shape({
name: React.PropTypes.string,
prettifiedText: React.PropTypes.string
}).isRequired,
files: React.PropTypes.array.isRequired,
handleRetryFiles: React.PropTypes.func.isRequired
},
getRetryButton(text, openIntercom) {
return (
<button
type="button"
className='btn btn-default'
onClick={() => {
if (openIntercom) {
window.Intercom('showNewMessage', getLangText("I'm having trouble uploading my file."));
}
this.retryAllFiles()
}}>
{getLangText(text)}
</button>
);
},
getContactUsDetail() {
return (
<div className='file-drag-and-drop-error'>
<h4>{getLangText('Let us help you')}</h4>
<p>{getLangText('Still having problems? Send us a message.')}</p>
{this.getRetryButton('Contact us', true)}
</div>
);
},
getErrorDetail(multipleFiles) {
const { errorClass: { prettifiedText }, files } = this.props;
return (
<div className='file-drag-and-drop-error'>
<div className={classNames('file-drag-and-drop-error-detail', { 'file-drag-and-drop-error-detail-multiple-files': multipleFiles })}>
<h4>{getLangText(multipleFiles ? 'Some files did not upload correctly'
: 'Error uploading the file!')}
</h4>
<p>{prettifiedText}</p>
{this.getRetryButton('Retry')}
</div>
<span className={classNames('file-drag-and-drop-error-icon-container', { 'file-drag-and-drop-error-icon-container-multiple-files': multipleFiles })}>
<span className='ascribe-icon icon-ascribe-thin-cross'></span>
</span>
<div className='file-drag-and-drop-error-file-names'>
<ul>
{files.map((file) => (<li key={file.id} className='file-name'>{file.originalName}</li>))}
</ul>
</div>
</div>
);
},
retryAllFiles() {
const { files, handleRetryFiles } = this.props;
handleRetryFiles(files.map(file => file.id));
},
render() {
const { errorClass: { name: errorName }, files } = this.props;
const multipleFiles = files.length > 1;
const contactUs = errorName === ErrorClasses.upload.contactUs.name;
return contactUs ? this.getContactUsDetail() : this.getErrorDetail(multipleFiles);
}
});
export default FileDragAndDropErrorDialog;

View File

@ -5,6 +5,7 @@ import React from 'react';
import FileDragAndDropPreviewImage from './file_drag_and_drop_preview_image';
import FileDragAndDropPreviewOther from './file_drag_and_drop_preview_other';
import { FileStatus } from '../react_s3_fine_uploader_utils';
import { getLangText } from '../../../utils/lang_utils';
import { truncateTextAtCharIndex } from '../../../utils/general_utils';
import { extractFileExtensionFromString } from '../../../utils/file_utils';
@ -24,27 +25,29 @@ const FileDragAndDropPreview = React.createClass({
s3UrlSafe: string
}).isRequired,
areAssetsDownloadable: bool,
areAssetsEditable: bool,
handleDeleteFile: func,
handleCancelFile: func,
handlePauseFile: func,
handleResumeFile: func,
areAssetsDownloadable: bool,
areAssetsEditable: bool,
numberOfDisplayedFiles: number
},
toggleUploadProcess() {
if (this.props.file.status === 'uploading') {
this.props.handlePauseFile(this.props.file.id);
} else if (this.props.file.status === 'paused') {
this.props.handleResumeFile(this.props.file.id);
const { file, handlePauseFile, handleResumeFile } = this.props;
if (file.status === FileStatus.UPLOADING) {
handlePauseFile(file.id);
} else if (file.status === FileStatus.PAUSED) {
handleResumeFile(file.id);
}
},
handleDeleteFile() {
const { handleDeleteFile,
handleCancelFile,
file } = this.props;
const { file,
handleDeleteFile,
handleCancelFile } = this.props;
// `handleDeleteFile` is optional, so if its not submitted, don't run it
//
// For delete though, we only want to trigger it, when we're sure that
@ -52,7 +55,7 @@ const FileDragAndDropPreview = React.createClass({
// deleted using an HTTP DELETE request.
if (handleDeleteFile &&
file.progress === 100 &&
(file.status === 'upload successful' || file.status === 'online') &&
(file.status === FileStatus.UPLOAD_SUCCESSFUL || file.status === FileStatus.ONLINE) &&
file.s3UrlSafe) {
handleDeleteFile(file.id);
} else if (handleCancelFile) {

View File

@ -3,7 +3,7 @@
import React from 'react';
import classNames from 'classnames';
import { displayValidProgressFilesFilter } from '../react_s3_fine_uploader_utils';
import { displayValidProgressFilesFilter, FileStatus } from '../react_s3_fine_uploader_utils';
import { getLangText } from '../../../utils/lang_utils';
import { truncateTextAtCharIndex } from '../../../utils/general_utils';
@ -58,11 +58,11 @@ export default function UploadButton({ className = 'btn btn-default btn-sm', sho
},
getUploadingFiles(filesToUpload = this.props.filesToUpload) {
return filesToUpload.filter((file) => file.status === 'uploading');
return filesToUpload.filter((file) => file.status === FileStatus.UPLOADING);
},
getUploadedFile() {
return this.props.filesToUpload.filter((file) => file.status === 'upload successful')[0];
return this.props.filesToUpload.filter((file) => file.status === FileStatus.UPLOAD_SUCCESSFUL)[0];
},
clearSelection() {

View File

@ -8,12 +8,16 @@ import S3Fetcher from '../../fetchers/s3_fetcher';
import FileDragAndDrop from './ascribe_file_drag_and_drop/file_drag_and_drop';
import ErrorQueueStore from '../../stores/error_queue_store';
import GlobalNotificationModel from '../../models/global_notification_model';
import GlobalNotificationActions from '../../actions/global_notification_actions';
import AppConstants from '../../constants/application_constants';
import { ErrorClasses, testErrorAgainstAll } from '../../constants/error_constants';
import { RETRY_ATTEMPT_TO_SHOW_CONTACT_US } from '../../constants/uploader_constants';
import { displayValidFilesFilter, transformAllowedExtensionsToInputAcceptProp } from './react_s3_fine_uploader_utils';
import { displayValidFilesFilter, FileStatus, transformAllowedExtensionsToInputAcceptProp } from './react_s3_fine_uploader_utils';
import { getCookie } from '../../utils/fetch_api_utils';
import { computeHashOfFile, extractFileExtensionFromString } from '../../utils/file_utils';
import { getLangText } from '../../utils/lang_utils';
@ -33,80 +37,18 @@ const { shape,
const ReactS3FineUploader = React.createClass({
propTypes: {
keyRoutine: shape({
url: string,
fileClass: string,
pieceId: number
}),
createBlobRoutine: shape({
url: string,
pieceId: number
}),
handleChangedFile: func, // is for when a file is dropped or selected
submitFile: func, // is for when a file has been successfully uploaded, TODO: rename to handleSubmitFile
onValidationFailed: func,
autoUpload: bool,
debug: bool,
objectProperties: shape({
acl: string
}),
request: shape({
endpoint: string,
accessKey: string,
params: shape({
csrfmiddlewaretoken: string
})
}),
signature: shape({
endpoint: string
}).isRequired,
uploadSuccess: shape({
method: string,
endpoint: string,
params: shape({
isBrowserPreviewCapable: any, // maybe fix this later
bitcoin_ID_noPrefix: string
})
}),
cors: shape({
expected: bool
}),
chunking: shape({
enabled: bool
}),
resume: shape({
enabled: bool
}),
deleteFile: shape({
enabled: bool,
method: string,
endpoint: string,
customHeaders: object
}).isRequired,
session: shape({
customHeaders: object,
endpoint: string,
params: object,
refreshOnRequests: bool
}),
validation: shape({
itemLimit: number,
sizeLimit: number,
allowedExtensions: arrayOf(string)
}),
messages: shape({
unsupportedBrowser: string
}),
formatFileName: func,
multiple: bool,
retry: shape({
enableAuto: bool
}),
setIsUploadReady: func,
isReadyForFormSubmission: func,
areAssetsDownloadable: bool,
areAssetsEditable: bool,
defaultErrorMessage: string,
errorNotificationMessage: string,
handleChangedFile: func, // for when a file is dropped or selected, TODO: rename to onChangedFile
submitFile: func, // for when a file has been successfully uploaded, TODO: rename to onSubmitFile
onValidationFailed: func,
setWarning: func, // for when the parent component wants to be notified of uploader warnings (ie. upload failed)
showErrorPrompt: bool,
// Handle form validation
setIsUploadReady: func, //TODO: rename to setIsUploaderValidated
isReadyForFormSubmission: func,
// We encountered some cases where people had difficulties to upload their
// works to ascribe due to a slow internet connection.
@ -135,13 +77,94 @@ const ReactS3FineUploader = React.createClass({
fileInputElement: oneOfType([
func,
element
])
]),
// S3 helpers
createBlobRoutine: shape({
url: string,
pieceId: number
}),
keyRoutine: shape({
url: string,
fileClass: string,
pieceId: number
}),
// FineUploader options
debug: bool,
autoUpload: bool,
chunking: shape({
enabled: bool
}),
cors: shape({
expected: bool
}),
deleteFile: shape({
enabled: bool,
method: string,
endpoint: string,
customHeaders: object
}).isRequired,
formatFileName: func,
messages: shape({
unsupportedBrowser: string
}),
multiple: bool,
objectProperties: shape({
acl: string
}),
request: shape({
endpoint: string,
accessKey: string,
params: shape({
csrfmiddlewaretoken: string
})
}),
resume: shape({
enabled: bool
}),
retry: shape({
enableAuto: bool
}),
session: shape({
customHeaders: object,
endpoint: string,
params: object,
refreshOnRequests: bool
}),
signature: shape({
endpoint: string
}).isRequired,
uploadSuccess: shape({
method: string,
endpoint: string,
params: shape({
isBrowserPreviewCapable: any, // maybe fix this later
bitcoin_ID_noPrefix: string
})
}),
validation: shape({
itemLimit: number,
sizeLimit: number,
allowedExtensions: arrayOf(string)
})
},
getDefaultProps() {
return {
errorNotificationMessage: getLangText('Oops, we had a problem uploading your file. Please contact us if this happens repeatedly.'),
showErrorPrompt: false,
fileClassToUpload: {
singular: getLangText('file'),
plural: getLangText('files')
},
fileInputElement: FileDragAndDrop,
// FineUploader options
autoUpload: true,
debug: false,
multiple: false,
objectProperties: {
acl: 'public-read',
bucket: 'ascribe0'
@ -178,27 +201,25 @@ const ReactS3FineUploader = React.createClass({
messages: {
unsupportedBrowser: '<h3>' + getLangText('Upload is not functional in IE7 as IE7 has no support for CORS!') + '</h3>'
},
formatFileName: function(name){// fix maybe
formatFileName: function(name) { // fix maybe
if (name !== undefined && name.length > 26) {
name = name.slice(0, 15) + '...' + name.slice(-15);
}
return name;
},
multiple: false,
defaultErrorMessage: getLangText('Unexpected error. Please contact us if this happens repeatedly.'),
fileClassToUpload: {
singular: getLangText('file'),
plural: getLangText('files')
},
fileInputElement: FileDragAndDrop
}
};
},
getInitialState() {
return {
filesToUpload: [],
uploader: new fineUploader.s3.FineUploaderBasic(this.propsToConfig()),
uploader: this.createNewFineUploader(),
csrfToken: getCookie(AppConstants.csrftoken),
errorState: {
manualRetryAttempt: 0,
errorClass: null
},
uploadInProgress: false,
// -1: aborted
// -2: uninitialized
@ -216,7 +237,7 @@ const ReactS3FineUploader = React.createClass({
let potentiallyNewCSRFToken = getCookie(AppConstants.csrftoken);
if(this.state.csrfToken !== potentiallyNewCSRFToken) {
this.setState({
uploader: new fineUploader.s3.FineUploaderBasic(this.propsToConfig()),
uploader: this.createNewFineUploader(),
csrfToken: potentiallyNewCSRFToken
});
}
@ -229,8 +250,12 @@ const ReactS3FineUploader = React.createClass({
this.state.uploader.cancelAll();
},
createNewFineUploader() {
return new fineUploader.s3.FineUploaderBasic(this.propsToConfig());
},
propsToConfig() {
let objectProperties = this.props.objectProperties;
const objectProperties = Object.assign({}, this.props.objectProperties);
objectProperties.key = this.requestKey;
return {
@ -251,6 +276,7 @@ const ReactS3FineUploader = React.createClass({
multiple: this.props.multiple,
retry: this.props.retry,
callbacks: {
onAllComplete: this.onAllComplete,
onComplete: this.onComplete,
onCancel: this.onCancel,
onProgress: this.onProgress,
@ -274,6 +300,9 @@ const ReactS3FineUploader = React.createClass({
// proclaim that upload is not ready
this.props.setIsUploadReady(false);
// reset any warnings propagated to parent
this.setWarning(false);
// reset internal data structures of component
this.setState(this.getInitialState());
},
@ -319,7 +348,7 @@ const ReactS3FineUploader = React.createClass({
// if createBlobRoutine is not defined,
// we're progressing right away without posting to S3
// so that this can be done manually by the form
if(!createBlobRoutine) {
if (!createBlobRoutine) {
// still we warn the user of this component
console.warn('createBlobRoutine was not defined for ReactS3FineUploader. Continuing without creating the blob on the server.');
resolve();
@ -377,6 +406,19 @@ const ReactS3FineUploader = React.createClass({
this.clearFileSelection();
},
checkFormSubmissionReady() {
const { isReadyForFormSubmission, setIsUploadReady } = this.props;
// since the form validation props isReadyForFormSubmission and setIsUploadReady
// are optional, we'll only trigger them when they're actually defined
if (typeof isReadyForFormSubmission === 'function' && typeof setIsUploadReady === 'function') {
// set uploadReady to true if the uploader's ready for submission
setIsUploadReady(isReadyForFormSubmission(this.state.filesToUpload));
} else {
console.warn('You didn\'t define the functions isReadyForFormSubmission and/or setIsUploadReady in as a prop in react-s3-fine-uploader');
}
},
clearFileSelection() {
const { fileInput } = this.refs;
if (fileInput && typeof fileInput.clearSelection === 'function') {
@ -394,6 +436,30 @@ const ReactS3FineUploader = React.createClass({
}
},
getUploadErrorClass({ type = 'upload', reason, xhr }) {
const { manualRetryAttempt } = this.state.errorState;
let matchedErrorClass;
if ('onLine' in window.navigator && !window.navigator.onLine) {
// If the user's offline, this is definitely the most important error to show.
// TODO: use a better mechanism for checking network state, ie. offline.js
matchedErrorClass = ErrorClasses.upload.offline;
} else if (manualRetryAttempt === RETRY_ATTEMPT_TO_SHOW_CONTACT_US) {
// Use the contact us error class if they've retried a number of times
// and are still unsuccessful
matchedErrorClass = ErrorClasses.upload.contactUs;
} else {
matchedErrorClass = testErrorAgainstAll({ type, reason, xhr });
if (!matchedErrorClass) {
// If none found, show the next error message in the queue for upload errors
matchedErrorClass = ErrorQueueStore.getNextError('upload');
}
}
return matchedErrorClass;
},
getXhrErrorComment(xhr) {
if (xhr) {
return {
@ -406,13 +472,20 @@ const ReactS3FineUploader = React.createClass({
},
isDropzoneInactive() {
const filesToDisplay = this.state.filesToUpload.filter((file) => file.status !== 'deleted' && file.status !== 'canceled' && file.size !== -1);
const { areAssetsEditable, enableLocalHashing, multiple, showErrorPrompt, uploadMethod } = this.props;
const { errorState, filesToUpload } = this.state;
if ((this.props.enableLocalHashing && !this.props.uploadMethod) || !this.props.areAssetsEditable || !this.props.multiple && filesToDisplay.length > 0) {
return true;
} else {
return false;
}
const filesToDisplay = filesToUpload.filter((file) => {
return file.status !== FileStatus.DELETED &&
file.status !== FileStatus.CANCELED &&
file.status !== FileStatus.UPLOAD_FAILED &&
file.size !== -1;
});
return (enableLocalHashing && !uploadMethod) ||
!areAssetsEditable ||
(showErrorPrompt && errorState.errorClass) ||
(!multiple && filesToDisplay.length > 0);
},
isFileValid(file) {
@ -457,7 +530,7 @@ const ReactS3FineUploader = React.createClass({
return Q.Promise((resolve) => {
let changeSet = {};
if(status === 'deleted' || status === 'canceled') {
if (status === FileStatus.DELETED || status === FileStatus.CANCELED || status === FileStatus.UPLOAD_FAILED) {
changeSet.progress = { $set: 0 };
}
@ -484,8 +557,13 @@ const ReactS3FineUploader = React.createClass({
}
},
/* FineUploader specific callback function handlers */
setWarning(hasWarning) {
if (typeof this.props.setWarning === 'function') {
this.props.setWarning(hasWarning);
}
},
/* FineUploader specific callback function handlers */
onUploadChunk(id, name, chunkData) {
let chunks = this.state.chunks;
@ -514,7 +592,14 @@ const ReactS3FineUploader = React.createClass({
this.setState({ startedChunks });
}
},
onAllComplete(succeed, failed) {
if (this.state.uploadInProgress) {
this.setState({
uploadInProgress: false
});
}
},
onComplete(id, name, res, xhr) {
@ -526,12 +611,12 @@ const ReactS3FineUploader = React.createClass({
xhr: this.getXhrErrorComment(xhr)
});
// onError will catch any errors, so we can ignore them here
} else if (!res.error || res.success) {
} else if (!res.error && res.success) {
let files = this.state.filesToUpload;
// Set the state of the completed file to 'upload successful' in order to
// remove it from the GUI
files[id].status = 'upload successful';
files[id].status = FileStatus.UPLOAD_SUCCESSFUL;
files[id].key = this.state.uploader.getKey(id);
let filesToUpload = React.addons.update(this.state.filesToUpload, { $set: files });
@ -540,29 +625,14 @@ const ReactS3FineUploader = React.createClass({
// Only after the blob has been created server-side, we can make the form submittable.
this.createBlob(files[id])
.then(() => {
// since the form validation props isReadyForFormSubmission, setIsUploadReady and submitFile
// are optional, we'll only trigger them when they're actually defined
if(this.props.submitFile) {
if (typeof this.props.submitFile === 'function') {
this.props.submitFile(files[id]);
} else {
console.warn('You didn\'t define submitFile as a prop in react-s3-fine-uploader');
}
// for explanation, check comment of if statement above
if(this.props.isReadyForFormSubmission && this.props.setIsUploadReady) {
// also, lets check if after the completion of this upload,
// the form is ready for submission or not
if(this.props.isReadyForFormSubmission(this.state.filesToUpload)) {
// if so, set uploadstatus to true
this.props.setIsUploadReady(true);
} else {
this.props.setIsUploadReady(false);
}
} else {
console.warn('You didn\'t define the functions isReadyForFormSubmission and/or setIsUploadReady in as a prop in react-s3-fine-uploader');
}
})
.catch(this.onErrorPromiseProxy);
this.checkFormSubmissionReady();
});
}
},
@ -578,22 +648,51 @@ const ReactS3FineUploader = React.createClass({
},
onError(id, name, errorReason, xhr) {
const { errorNotificationMessage, showErrorPrompt } = this.props;
const { chunks, filesToUpload } = this.state;
console.logGlobal(errorReason, {
files: this.state.filesToUpload,
chunks: this.state.chunks,
files: filesToUpload,
chunks: chunks,
xhr: this.getXhrErrorComment(xhr)
});
this.props.setIsUploadReady(true);
this.cancelUploads();
let notificationMessage;
let notification = new GlobalNotificationModel(errorReason || this.props.defaultErrorMessage, 'danger', 5000);
GlobalNotificationActions.appendGlobalNotification(notification);
if (showErrorPrompt) {
this.setStatusOfFile(id, FileStatus.UPLOAD_FAILED);
// If we've already found an error on this upload, just ignore other errors
// that pop up. They'll likely pop up again when the user retries.
if (!this.state.errorState.errorClass) {
notificationMessage = errorNotificationMessage;
const errorState = React.addons.update(this.state.errorState, {
errorClass: {
$set: this.getUploadErrorClass({
reason: errorReason,
xhr
})
}
});
this.setState({ errorState });
this.setWarning(true);
}
} else {
notificationMessage = errorReason || errorNotificationMessage;
this.cancelUploads();
}
if (notificationMessage) {
const notification = new GlobalNotificationModel(notificationMessage, 'danger', 5000);
GlobalNotificationActions.appendGlobalNotification(notification);
}
},
onCancel(id) {
// when a upload is canceled, we need to update this components file array
this.setStatusOfFile(id, 'canceled')
this.setStatusOfFile(id, FileStatus.CANCELED)
.then(() => {
if(typeof this.props.handleChangedFile === 'function') {
this.props.handleChangedFile(this.state.filesToUpload[id]);
@ -603,17 +702,18 @@ const ReactS3FineUploader = React.createClass({
let notification = new GlobalNotificationModel(getLangText('File upload canceled'), 'success', 5000);
GlobalNotificationActions.appendGlobalNotification(notification);
// since the form validation props isReadyForFormSubmission, setIsUploadReady and submitFile
// are optional, we'll only trigger them when they're actually defined
if(this.props.isReadyForFormSubmission && this.props.setIsUploadReady) {
if(this.props.isReadyForFormSubmission(this.state.filesToUpload)) {
// if so, set uploadstatus to true
this.props.setIsUploadReady(true);
} else {
this.props.setIsUploadReady(false);
}
} else {
console.warn('You didn\'t define the functions isReadyForFormSubmission and/or setIsUploadReady in as a prop in react-s3-fine-uploader');
this.checkFormSubmissionReady();
// FineUploader's onAllComplete event doesn't fire if all files are cancelled
// so we need to double check if this is the last file getting cancelled.
//
// Because we're calling FineUploader.getInProgress() in a cancel callback,
// the current file getting cancelled is still considered to be in progress
// so there will be one file left in progress when we're cancelling the last file.
if (this.state.uploader.getInProgress() === 1) {
this.setState({
uploadInProgress: false
});
}
return true;
@ -633,7 +733,7 @@ const ReactS3FineUploader = React.createClass({
// fetch blobs for images
response = response.map((file) => {
file.url = file.s3UrlSafe;
file.status = 'online';
file.status = FileStatus.ONLINE;
file.progress = 100;
return file;
});
@ -661,7 +761,7 @@ const ReactS3FineUploader = React.createClass({
onDeleteComplete(id, xhr, isError) {
if(isError) {
this.setStatusOfFile(id, 'online');
this.setStatusOfFile(id, FileStatus.ONLINE);
let notification = new GlobalNotificationModel(getLangText('There was an error deleting your file.'), 'danger', 10000);
GlobalNotificationActions.appendGlobalNotification(notification);
@ -670,29 +770,16 @@ const ReactS3FineUploader = React.createClass({
GlobalNotificationActions.appendGlobalNotification(notification);
}
// since the form validation props isReadyForFormSubmission, setIsUploadReady and submitFile
// are optional, we'll only trigger them when they're actually defined
if(this.props.isReadyForFormSubmission && this.props.setIsUploadReady) {
// also, lets check if after the completion of this upload,
// the form is ready for submission or not
if(this.props.isReadyForFormSubmission(this.state.filesToUpload)) {
// if so, set uploadstatus to true
this.props.setIsUploadReady(true);
} else {
this.props.setIsUploadReady(false);
}
} else {
console.warn('You didn\'t define the functions isReadyForFormSubmission and/or setIsUploadReady in as a prop in react-s3-fine-uploader');
}
this.checkFormSubmissionReady();
},
handleDeleteFile(fileId) {
// We set the files state to 'deleted' immediately, so that the user is not confused with
// the unresponsiveness of the UI
//
// If there is an error during the deletion, we will just change the status back to 'online'
// If there is an error during the deletion, we will just change the status back to FileStatus.ONLINE
// and display an error message
this.setStatusOfFile(fileId, 'deleted')
this.setStatusOfFile(fileId, FileStatus.DELETED)
.then(() => {
if(typeof this.props.handleChangedFile === 'function') {
this.props.handleChangedFile(this.state.filesToUpload[fileId]);
@ -708,7 +795,7 @@ const ReactS3FineUploader = React.createClass({
// To check which files are already uploaded from previous sessions we check their status.
// If they are, it is "online"
if(this.state.filesToUpload[fileId].status !== 'online') {
if(this.state.filesToUpload[fileId].status !== FileStatus.ONLINE) {
// delete file from server
this.state.uploader.deleteFile(fileId);
// this is being continued in onDeleteFile, as
@ -736,7 +823,7 @@ const ReactS3FineUploader = React.createClass({
handlePauseFile(fileId) {
if(this.state.uploader.pauseUpload(fileId)) {
this.setStatusOfFile(fileId, 'paused');
this.setStatusOfFile(fileId, FileStatus.PAUSED);
} else {
throw new Error(getLangText('File upload could not be paused.'));
}
@ -744,12 +831,35 @@ const ReactS3FineUploader = React.createClass({
handleResumeFile(fileId) {
if(this.state.uploader.continueUpload(fileId)) {
this.setStatusOfFile(fileId, 'uploading');
this.setStatusOfFile(fileId, FileStatus.UPLOADING);
} else {
throw new Error(getLangText('File upload could not be resumed.'));
}
},
handleRetryFiles(fileIds) {
let filesToUpload = this.state.filesToUpload;
if (fileIds.constructor !== Array) {
fileIds = [ fileIds ];
}
fileIds.forEach((fileId) => {
this.state.uploader.retry(fileId);
filesToUpload = React.addons.update(filesToUpload, { [fileId]: { status: { $set: FileStatus.UPLOADING } } });
});
this.setState({
// Reset the error class along with the retry
errorState: {
manualRetryAttempt: this.state.errorState.manualRetryAttempt + 1
},
filesToUpload
});
this.setWarning(false);
},
handleUploadFile(files) {
// While files are being uploaded, the form cannot be ready
// for submission
@ -870,6 +980,9 @@ const ReactS3FineUploader = React.createClass({
if(files.length > 0) {
this.state.uploader.addFiles(files);
this.synchronizeFileLists(files);
this.setState({
uploadInProgress: true
});
}
}
},
@ -910,12 +1023,12 @@ const ReactS3FineUploader = React.createClass({
//
// If the user deletes one of those files, then fineuploader will still keep it in his
// files array but with key, progress undefined and size === -1 but
// status === 'upload successful'.
// status === FileStatus.UPLOAD_SUCCESSFUL.
// This poses a problem as we depend on the amount of files that have
// status === 'upload successful', therefore once the file is synced,
// we need to tag its status as 'deleted' (which basically happens here)
// status === FileStatus.UPLOAD_SUCCESSFUL, therefore once the file is synced,
// we need to tag its status as FileStatus.DELETED (which basically happens here)
if(oldAndNewFiles[i].size === -1 && (!oldAndNewFiles[i].progress || oldAndNewFiles[i].progress === 0)) {
oldAndNewFiles[i].status = 'deleted';
oldAndNewFiles[i].status = FileStatus.DELETED;
}
if(oldAndNewFiles[i].originalName === oldFiles[j].name) {
@ -944,14 +1057,19 @@ const ReactS3FineUploader = React.createClass({
},
render() {
const { errorState: { errorClass }, filesToUpload, uploadInProgress } = this.state;
const {
multiple,
areAssetsDownloadable,
areAssetsEditable,
enableLocalHashing,
fileClassToUpload,
fileInputElement: FileInputElement,
uploadMethod } = this.props;
areAssetsDownloadable,
areAssetsEditable,
enableLocalHashing,
fileClassToUpload,
fileInputElement: FileInputElement,
multiple,
showErrorPrompt,
uploadMethod } = this.props;
// Only show the error state once all files are finished
const showError = !uploadInProgress && showErrorPrompt && errorClass != null;
const props = {
multiple,
@ -960,12 +1078,16 @@ const ReactS3FineUploader = React.createClass({
enableLocalHashing,
uploadMethod,
fileClassToUpload,
filesToUpload,
uploadInProgress,
errorClass,
showError,
onDrop: this.handleUploadFile,
filesToUpload: this.state.filesToUpload,
handleDeleteFile: this.handleDeleteFile,
handleCancelFile: this.handleCancelFile,
handlePauseFile: this.handlePauseFile,
handleResumeFile: this.handleResumeFile,
handleRetryFiles: this.handleRetryFiles,
handleCancelHashing: this.handleCancelHashing,
dropzoneInactive: this.isDropzoneInactive(),
hashingProgress: this.state.hashingProgress,

View File

@ -1,7 +1,15 @@
'use strict';
import fineUploader from 'fineUploader';
import MimeTypes from '../../constants/mime_types';
// Re-export qq.status from FineUploader with an additional online
// state that we use to keep track of files from S3.
export const FileStatus = Object.assign({}, fineUploader.status, {
ONLINE: 'online'
});
export const formSubmissionValidation = {
/**
* Returns a boolean if there has been at least one file uploaded
@ -10,8 +18,13 @@ export const formSubmissionValidation = {
* @return {boolean}
*/
atLeastOneUploadedFile(files) {
files = files.filter((file) => file.status !== 'deleted' && file.status !== 'canceled');
if (files.length > 0 && files[0].status === 'upload successful') {
files = files.filter((file) => {
return file.status !== FileStatus.DELETED &&
file.status !== FileStatus.CANCELED &&
file.status != FileStatus.UPLOADED_FAILED
});
if (files.length && files[0].status === FileStatus.UPLOAD_SUCCESSFUL) {
return true;
} else {
return false;
@ -25,32 +38,32 @@ export const formSubmissionValidation = {
* @return {boolean} [description]
*/
fileOptional(files) {
let uploadingFiles = files.filter((file) => file.status === 'submitting');
const uploadingFiles = files.filter((file) => file.status === FileStatus.SUBMITTING);
if (uploadingFiles.length === 0) {
return true;
} else {
return false;
}
return uploadFiles.length === 0;
}
};
/**
* Filter function for filtering all deleted and canceled files
* Filter function for filtering all deleted, canceled, and failed files
* @param {object} file A file from filesToUpload that has status as a prop.
* @return {boolean}
*/
export function displayValidFilesFilter(file) {
return file.status !== 'deleted' && file.status !== 'canceled';
return file.status !== FileStatus.DELETED &&
file.status !== FileStatus.CANCELED &&
file.status !== FileStatus.UPLOAD_FAILED;
}
/**
* Filter function for filtering all files except for deleted and canceled files
* Filter function for filtering all files except for deleted, canceled, and failed files
* @param {object} file A file from filesToUpload that has status as a prop.
* @return {boolean}
*/
export function displayRemovedFilesFilter(file) {
return file.status === 'deleted' || file.status === 'canceled';
return file.status === FileStatus.DELETED ||
file.status === FileStatus.CANCELED ||
file.status === FileStatus.UPLOAD_FAILED;
}
@ -60,7 +73,10 @@ export function displayRemovedFilesFilter(file) {
* @return {boolean}
*/
export function displayValidProgressFilesFilter(file) {
return file.status !== 'deleted' && file.status !== 'canceled' && file.status !== 'online';
return file.status !== FileStatus.DELETED &&
file.status !== FileStatus.CANCELED &&
file.status !== FileStatus.UPLOAD_FAILED &&
file.status !== FileStatus.ONLINE;
}
@ -77,7 +93,7 @@ export function displayValidProgressFilesFilter(file) {
export function transformAllowedExtensionsToInputAcceptProp(allowedExtensions) {
// Get the mime type of the extension if it's defined or add a dot in front of the extension
// This is important for Safari as it doesn't understand just the extension.
let prefixedAllowedExtensions = allowedExtensions.map((ext) => {
const prefixedAllowedExtensions = allowedExtensions.map((ext) => {
return MimeTypes[ext] || ('.' + ext);
});

View File

@ -0,0 +1,227 @@
'use strict'
import { validationParts } from './uploader_constants';
import { deepMatchObject } from '../utils/general_utils';
import { getLangText } from '../utils/lang_utils';
/**
* ErrorClasses
* ============
* Known error classes based on groupings (ie. where they happened, which component, etc).
*
* Error classes have a test object that can be used to test whether or not an error
* object matches that specific class. Properties in the test object will be recursively
* checked in the error object, and the error object is only matched to the class if all
* tests succeed. See testErrorAgainstClass() below for the implementation of the matching.
*
* ErrorClasses.default.default is the generic error for errors not identified under any
* grouping and class.
*
* Format:
* ErrorClasses = {
* 'errorGrouping': {
* 'errorClassName': ErrorClass
* ...
* },
* ...
* 'default': {
* ...
* 'default': generic error for errors that don't fall under any grouping and class
* }
* }
*
* Each class is of the format:
* ErrorClass = {
* 'name': name of the class
* 'group': grouping of the error,
* 'prettifiedText': prettified text for the class
* 'test': {
* prop1: property in the error object to recursively match against using
* either === or, if the property is a string, substring match
* (ie. indexOf() >= 0)
* ...
* },
* }
*
* Test object examples
* ====================
* A class like this:
*
* 'errorClass': {
* 'test': {
* 'reason': 'Invalid server response',
* 'xhr': {
* 'response': 'Internal error',
* 'status': 500
* }
* }
* }
*
* will match this error object:
*
* error = {
* 'reason': 'Invalid server response',
* 'xhr': { // Simplified version of the XMLHttpRequest object responsible for the failure
* 'response': 'Internal error',
* 'status': 500
* }
* }
*
* but will *NOT* match this error object:
*
* error = {
* 'reason': 'Invalid server response',
* 'xhr': {
* 'response': 'Unauthorized',
* 'status': 401
* }
* }
*
* A common use case is for the test to just be against the error.reason string.
* In these cases, setting the test object to be just a string will enforce this test,
* so something like this:
*
* 'errorClass': {
* 'test': {
* 'reason': 'Invalid server response'
* }
* }
*
* is the same as:
*
* 'errorClass': {
* 'test': 'Invalid server response'
* }
*/
const ErrorClasses = {
'upload': {
'requestTimeTooSkewed': {
'prettifiedText': getLangText('Check your time and date preferences and select "set date and time ' +
'automatically." Being off by a few minutes from our servers can ' +
'prevent your upload.'),
'test': {
'xhr': {
'response': 'RequestTimeTooSkewed'
}
}
},
'chunkSignatureError': {
'prettifiedText': getLangText("We're experiencing some problems with uploads at the moment and " +
'are working to resolve them. Please try again in a few hours.'),
'test': 'Problem signing the chunk'
},
// Fallback error tips
'largeFileSize': {
'prettifiedText': getLangText(`We handle files up to ${validationParts.sizeLimit.default / 1000000000}GB ` +
'but your Internet connection may not. With large files and limited ' +
'bandwith, it may take some time to complete. If it doesnt seem to ' +
'progress at all, try restarting the process.')
},
'tryDifferentBrowser': {
'prettifiedText': getLangText("We're still having trouble uploading your file. It might be your " +
"browser; try a different browser or make sure youre using the " +
'latest version.')
},
'contactUs': {
'prettifiedText': getLangText("We're having a really hard time with your upload. Please contact us for more help.")
},
'offline': {
'prettifiedText': getLangText('It looks like your Internet connection might have gone down during the upload. Please check your connection and try again.')
}
},
'default': {
'default': {
'prettifiedText': getLangText("It looks like there's been a problem on our end. If you keep experiencing this error, please contact us.")
}
}
};
// Dynamically inject the name and group properties into the classes
Object.keys(ErrorClasses).forEach((errorGroupKey) => {
const errorGroup = ErrorClasses[errorGroupKey];
Object.keys(errorGroup).forEach((errorClassKey) => {
const errorClass = errorGroup[errorClassKey];
errorClass.name = errorGroupKey + '-' + errorClassKey;
errorClass.group = errorGroupKey;
});
});
/**
* Returns prettified text for a given error by trying to match it to
* a known error in ErrorClasses or the given class.
*
* One should provide a class (eg. ErrorClasses.upload.requestTimeTooSkewed)
* if they already have an error in mind that they want to match against rather
* than all the available error classes.
*
* @param {object} error An error with the following:
* @param {string} error.type Type of error
* @param {string} error.reason Reason of error
* @param {(XMLHttpRequest)} error.xhr XHR associated with the error
* @param {(any)} error.* Any other property as necessary
*
* @param {(object)} errorClass ErrorClass to match against the given error.
* Signature should be similar to ErrorClasses' classes (see above).
* @param {object|string} errorClass.test Test object to recursively match against the given error
* @param {string} errorClass.prettifiedText Prettified text to return if the test matches
*
* @return {string} Prettified error string. Returns the default error string if no
* error class was matched to the given error.
*/
function getPrettifiedError(error, errorClass) {
const matchedClass = errorClass ? testErrorAgainstClass(error, errorClass) : testErrorAgainstAll(error);
return (matchedClass && matchedClass.prettifiedText) || ErrorClasses.default.default.prettifiedText;
}
/**
* Tests the given error against all items in ErrorClasses and returns
* the matching class if available.
* See getPrettifiedError() for the signature of @param error.
* @return {(object)} Matched error class
*/
function testErrorAgainstAll(error) {
const type = error.type || 'default';
const errorGroup = ErrorClasses[type];
return Object
.keys(errorGroup)
.reduce((result, key) => {
return result || testErrorAgainstClass(error, errorGroup[key]);
}, null);
}
/**
* Tests the error against the class by recursively testing the
* class's test object against the error.
* Implements the test matching behaviour described in ErrorClasses.
*
* See getPrettifiedError() for the signatures of @param error and @param errorClass.
* @return {(object)} Returns the given class if the test succeeds.
*/
function testErrorAgainstClass(error, errorClass) {
// Automatically fail classes if no tests present, since some of the error classes
// may not have an error to test against.
if (!errorClass.test) {
return;
}
if (typeof errorClass.test === 'string') {
errorClass.test = {
reason: errorClass.test
};
}
return deepMatchObject(error, errorClass.test, (objProp, matchProp) => {
return (objProp === matchProp || (typeof objProp === 'string' && objProp.indexOf(matchProp) >= 0));
}) ? errorClass : null;
}
// Need to export with the clause syntax as we change ErrorClasses after its declaration.
export {
ErrorClasses,
getPrettifiedError,
testErrorAgainstAll,
testErrorAgainstClass
};

View File

@ -30,3 +30,6 @@ export const validationTypes = {
sizeLimit: sizeLimit.thumbnail
}
};
// Number of manual retries before showing a contact us screen on the uploader.
export const RETRY_ATTEMPT_TO_SHOW_CONTACT_US = 5;

View File

@ -0,0 +1,56 @@
'use strict';
import { alt } from '../alt';
import ErrorQueueActions from '../actions/error_queue_actions.js';
import { ErrorClasses } from '../constants/error_constants.js';
class ErrorQueueStore {
constructor() {
const { upload: { largeFileSize, tryDifferentBrowser } } = ErrorClasses;
this.errorQueues = {
'upload': {
queue: [largeFileSize, tryDifferentBrowser],
loop: true
}
};
// Add intial index to each error queue
Object
.keys(this.errorQueues)
.forEach((type) => {
this.errorQueues[type].index = 0;
});
// Bind the exported functions to this instance
this.getNextError = this.getNextError.bind(this);
this.exportPublicMethods({
getNextError: this.getNextError
});
this.bindActions(ErrorQueueActions);
}
getNextError(type) {
const errorQueue = this.errorQueues[type];
const { queue, index } = errorQueue;
ErrorQueueActions.shiftErrorQueue(type);
return queue[index];
}
onShiftErrorQueue(type) {
const errorQueue = this.errorQueues[type];
const { queue, loop } = errorQueue;
++errorQueue.index;
if (loop) {
// Loop back to the beginning if all errors have been exhausted
errorQueue.index %= queue.length;
}
}
}
export default alt.createStore(ErrorQueueStore, 'ErrorQueueStore');

View File

@ -4,7 +4,6 @@ import Raven from 'raven-js';
import AppConstants from '../constants/application_constants';
/**
* Logs an error in to the console but also sends it to
* Sentry.

View File

@ -223,15 +223,12 @@ export function omitFromObject(obj, filter) {
* By default, applies strict equality using ===
* @return {boolean} True if obj matches the "match" object
*/
export function deepMatchObject(obj, match, testFn) {
export function deepMatchObject(obj, match, testFn = (objProp, matchProp) => objProp === matchProp) {
if (typeof match !== 'object') {
throw new Error('Your specified match argument was not an object');
}
if (typeof testFn !== 'function') {
testFn = (objProp, matchProp) => {
return objProp === matchProp;
};
throw new Error('Your specified test function was not a function');
}
return Object
@ -239,7 +236,7 @@ export function deepMatchObject(obj, match, testFn) {
.reduce((result, matchKey) => {
if (!result) { return false; }
const objProp = obj[matchKey];
const objProp = obj && obj[matchKey];
const matchProp = match[matchKey];
if (typeof matchProp === 'object') {

View File

@ -48,6 +48,10 @@ $ascribe-red-error: rgb(169, 68, 66);
}
}
.is-warning {
border-left: 3px solid $ascribe-pink
}
.is-fixed {
cursor: default;

View File

@ -160,6 +160,165 @@
}
}
.file-drag-and-drop-error {
color: #333333;
margin-bottom: 25px;
overflow: hidden;
text-align: center;
h4 {
margin-top: 0;
color: $ascribe-pink;
}
.btn {
padding-left: 45px;
padding-right: 45px;
}
/* Make button larger on mobile */
@media screen and (max-width: 625px) {
.btn {
padding: 10px 100px 10px 100px;
margin-bottom: 10px;
margin-top: 10px;
}
}
}
.file-drag-and-drop-error-detail {
float: left;
padding-right: 25px;
text-align: left;
width: 40%;
&.file-drag-and-drop-error-detail-multiple-files {
width: 45%;
}
/* Have detail fill up entire width on mobile */
@media screen and (max-width: 625px) {
text-align: center;
width: 100%;
&.file-drag-and-drop-error-detail-multiple-files {
width: 100%;
}
}
}
.file-drag-and-drop-error-file-names {
float: left;
height: 104px;
max-width: 35%;
padding-left: 25px;
position: relative;
text-align: left;
ul {
list-style: none;
padding-left: 0;
position: relative;
top: 50%;
-webkit-transform: translateY(-50%);
-ms-transform: translateY(-50%);
transform: translateY(-50%);
li {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
/* Drop down file names under the retry button on mobile */
@media screen and (max-width: 625px) {
height: auto;
max-width: none;
padding: 0;
text-align: center;
width: 100%;
ul {
margin-bottom: 0;
margin-top: 10px;
top: 0;
-webkit-transform: none;
-ms-transform: none;
transform: none;
li {
display: inline-block;
max-width: 25%;
padding-right: 10px;
&:not(:last-child)::after {
content: ',';
}
}
}
}
}
.file-drag-and-drop-error-icon-container {
background-color: #eeeeee;
display: table;
float: left;
height: 104px;
position: relative;
width: 104px;
vertical-align: top;
.icon-ascribe-thin-cross {
display: table-cell;
font-size: 5.5em;
vertical-align: middle;
}
&.file-drag-and-drop-error-icon-container-multiple-files {
background-color: #d7d7d7;
left: -15px;
margin-bottom: 24px;
z-index: 1;
&::before {
content: '';
background-color: #e9e9e9;
display: block;
height: 104px;
left: 15px;
position: absolute;
top: 12px;
width: 104px;
z-index: 2;
}
&::after {
content: '';
background-color: #f5f5f5;
display: block;
height: 104px;
left: 30px;
position: absolute;
top: 24px;
width: 104px;
z-index: 3;
}
.icon-ascribe-thin-cross {
left: 44px;
position: absolute;
top: 38px;
z-index: 4;
}
}
/* Hide the icon when the screen is too small */
@media screen and (max-width: 625px) {
display: none;
}
}
.ascribe-progress-bar {
margin-bottom: 0;
> .progress-bar {