mirror of
https://github.com/ascribe/onion.git
synced 2024-12-22 09:23:13 +01:00
Add rewritten uploader and UI elements
This commit is contained in:
parent
1069121e07
commit
d5469b3efa
126
js/components/ascribe_uploader/ui/upload_button.js
Normal file
126
js/components/ascribe_uploader/ui/upload_button.js
Normal file
@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Uploadify from 'react-utility-belt/es6/uploader/uploadify';
|
||||
import { UploadButtonBase } from 'react-utility-belt/es6/uploader/upload_button';
|
||||
import { uploadedFilesFilter, uploadingFilesFilter } from 'react-utility-belt/es6/uploader/utils/file_filters';
|
||||
import uploaderSpecExtender from 'react-utility-belt/es6/uploader/utils/uploader_spec_extender';
|
||||
|
||||
import Uploader from '../uploader';
|
||||
|
||||
import { safeInvoke } from '../../../utils/general';
|
||||
import { getLangText } from '../../../utils/lang';
|
||||
import { truncateText } from '../../../utils/text';
|
||||
|
||||
|
||||
const { arrayOf, func, object, shape, string } = React.PropTypes;
|
||||
|
||||
const FileLabel = ({ files, handleRemoveFiles }) => {
|
||||
let label;
|
||||
|
||||
if (files.length) {
|
||||
const uploadedFiles = files.filter(uploadedFilesFilter);
|
||||
const uploadingFiles = files.filter(uploadingFilesFilter);
|
||||
|
||||
const uploadedIcon = uploadedFiles.length && !uploadingFiles.length
|
||||
? (<span className="ascribe-icon icon-ascribe-ok uploader-button--label-icon" />)
|
||||
: null;
|
||||
|
||||
const labelText = files.length > 1 ? `${files.length} ${getLangText('files')}`
|
||||
: truncateText(files[0].name, 40);
|
||||
|
||||
const removeActionText = getLangText(
|
||||
uploadingFiles.length ? `cancel ${files.length > 1 ? 'uploads' : 'upload'}`
|
||||
: 'remove'
|
||||
);
|
||||
|
||||
label = [
|
||||
uploadedIcon,
|
||||
labelText,
|
||||
' [',
|
||||
(<a key="remove-link" onClick={handleRemoveFiles} tabIndex={0}>{removeActionText}</a>),
|
||||
']'
|
||||
];
|
||||
} else {
|
||||
label = getLangText('No file selected');
|
||||
}
|
||||
|
||||
return (<span className="upload-button--label">{label}</span>);
|
||||
};
|
||||
|
||||
FileLabel.propTypes = {
|
||||
files: arrayOf(shape({
|
||||
name: string.isRequired
|
||||
})).isRequired,
|
||||
|
||||
handleRemoveFiles: func.isRequired
|
||||
};
|
||||
|
||||
|
||||
const UploadButton = ({ className, ...props }) => (
|
||||
<UploadButtonBase {...props} className={classNames(className, 'upload-button')} />
|
||||
);
|
||||
|
||||
UploadButton.propTypes = {
|
||||
className: string
|
||||
};
|
||||
|
||||
UploadButton.defaultProps = {
|
||||
buttonType: 'button',
|
||||
getUploadingButtonLabel: (uploaderFiles, progress) => (
|
||||
`${getLangText('Upload progress')}: ${progress}%`
|
||||
),
|
||||
fileLabelType: FileLabel
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* We want to add some additional default uploader functionality on top of the UI defaults we've
|
||||
* added to the base upload button, so we wrap another class around the Uploadified instance of our
|
||||
* UploadButton and export that as our default export.
|
||||
*/
|
||||
const UploadifiedUploadButton = Uploadify(UploadButton);
|
||||
const UploadButtonUploadifyWrapper = React.createClass(uploaderSpecExtender({
|
||||
displayName: 'UploadbuttonUploadifyWrapper',
|
||||
|
||||
propTypes: {
|
||||
uploaderProps: object,
|
||||
uploaderType: func
|
||||
},
|
||||
|
||||
getDefaultProps() {
|
||||
return {
|
||||
uploaderType: Uploader
|
||||
};
|
||||
},
|
||||
|
||||
onError(file, ...args) {
|
||||
// Automatically cancel any files that fail.
|
||||
this.refs.uploader.handleCancelFile(file);
|
||||
|
||||
safeInvoke(this.props.uploaderProps.onError, file, ...args);
|
||||
},
|
||||
|
||||
render() {
|
||||
const { uploaderProps, ...restProps } = this.props;
|
||||
const props = {
|
||||
...restProps,
|
||||
|
||||
uploaderProps: {
|
||||
...uploaderProps,
|
||||
onError: this.onError
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UploadifiedUploadButton ref="uploader" {...props} />
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
export default UploadButtonUploadifyWrapper;
|
||||
|
||||
// Also export the non-uploadify version for extension
|
||||
export {
|
||||
UploadButton as UploadButtonBase
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
// Make it easier for users to import this component by default exporting the container in an
|
||||
// index.js
|
||||
export { default } from './upload_file_dialog';
|
||||
|
||||
// Components originally adapted from https://github.com/fedosejev/react-file-drag-and-drop
|
@ -0,0 +1,281 @@
|
||||
import React from 'react';
|
||||
import update from 'react-addons-update';
|
||||
|
||||
import UploadDragAndDropArea from 'react-utility-belt/es6/uploader/upload_drag_and_drop_area';
|
||||
import uploaderSpecExtender from 'react-utility-belt/es6/uploader/utils/uploader_spec_extender';
|
||||
|
||||
import ErrorQueueStore from '../../../../stores/error_queue_store';
|
||||
|
||||
import UploadFileDialogUI from './upload_file_dialog_ui';
|
||||
|
||||
import Uploader from '../../uploader';
|
||||
|
||||
import { ErrorClasses, testErrorAgainstAll } from '../../../../constants/error_constants';
|
||||
import { UploadMethods, RETRY_ATTEMPT_TO_SHOW_CONTACT_US } from '../../../../constants/uploader_constants';
|
||||
|
||||
import { safeInvoke } from '../../../../utils/general';
|
||||
import { getCurrentQueryParams } from '../../../../utils/url';
|
||||
|
||||
|
||||
const { bool, func, object, oneOf, shape, string } = React.PropTypes;
|
||||
|
||||
const UploadFileDialog = React.createClass(uploaderSpecExtender({
|
||||
propTypes: {
|
||||
/**
|
||||
* Upload method to use. Defaults to normal uploading of files as-received.
|
||||
*
|
||||
* If UploadMethods.USE_URL_PARAM is set, check the current url's upload_method parameter to
|
||||
* determine the upload method. For example, `https://www.ascribe.io/app/register_work?upload_method=hash`
|
||||
* will use file hashing before uploading.
|
||||
*/
|
||||
uploadMethod: oneOf([
|
||||
UploadMethods.HASH,
|
||||
UploadMethods.NORMAL,
|
||||
UploadMethods.USE_URL_PARAM
|
||||
]).isRequired,
|
||||
|
||||
/**
|
||||
* In the case that this dialog is used to load uploaded files from a previous session, it
|
||||
* can sometimes be useful to disallow the ability to download or edit (ie. delete) those
|
||||
* files.
|
||||
*/
|
||||
areAssetsDownloadable: bool,
|
||||
areAssetsEditable: bool,
|
||||
|
||||
className: string,
|
||||
disabled: bool,
|
||||
fileTypeNames: shape({
|
||||
plural: string.isRequired,
|
||||
singular: string.isRequired
|
||||
}),
|
||||
|
||||
// For Uploadify (and UploadDragAndDropArea)
|
||||
// eslint-disable-next-line react/sort-prop-types
|
||||
uploaderProps: object.isRequired,
|
||||
|
||||
uploaderType: func
|
||||
|
||||
// Note that any drag event callbacks specified through the props will be properly attached
|
||||
// to their events by UploadDragAndDropArea.
|
||||
},
|
||||
|
||||
getDefaultProps() {
|
||||
return {
|
||||
areAssetsDownloadable: true,
|
||||
areAssetsEditable: true,
|
||||
fileTypeNames: {
|
||||
plural: 'files',
|
||||
singular: 'file'
|
||||
},
|
||||
uploadMethod: UploadMethods.NORMAL,
|
||||
uploaderType: Uploader
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
errorClass: null,
|
||||
hashingFiles: [],
|
||||
manualRetryAttempt: 0,
|
||||
thumbnailMapping: {},
|
||||
uploadInProgress: false
|
||||
};
|
||||
},
|
||||
|
||||
getUploadErrorClass({ type = 'upload', reason, xhr }) {
|
||||
const { manualRetryAttempt } = this.state;
|
||||
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;
|
||||
},
|
||||
|
||||
setThumbnailForFile(file, thumbnailUrl) {
|
||||
if ('id' in file) {
|
||||
this.setState({
|
||||
thumbnailMapping: update(this.state.thumbnailMapping, {
|
||||
[file.id]: { $set: thumbnailUrl }
|
||||
})
|
||||
});
|
||||
} else {
|
||||
console.logGlobal(
|
||||
new Error('Attempt to set the thumbnail of a file without an id'),
|
||||
{ file, thumbnailUrl }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// Override Uploader's showErrorNotification to always show its generic message since we are
|
||||
// already displaying the error in this dialog
|
||||
showErrorNotification() {
|
||||
Uploader.showErrorNotification();
|
||||
},
|
||||
|
||||
onAllComplete(...args) {
|
||||
this.setState({ uploadInProgress: false });
|
||||
|
||||
safeInvoke(this.props.uploaderProps.onAllComplete, ...args);
|
||||
},
|
||||
|
||||
onError(file, errorReason, xhr, ...args) {
|
||||
// If we've already found an error, just ignore other errors that pop up. They'll likely
|
||||
// pop up again when the user retries.
|
||||
if (!this.state.errorClass) {
|
||||
this.setState({
|
||||
errorClass: this.getUploadErrorClass({
|
||||
reason: errorReason,
|
||||
xhr
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
safeInvoke(this.props.uploaderProps.onError, file, errorReason, xhr, ...args);
|
||||
},
|
||||
|
||||
onFileHashError(error, ...args) {
|
||||
// Clear our tracked hashing files since they've failed
|
||||
this.setState({
|
||||
hashingFiles: []
|
||||
});
|
||||
|
||||
const { onFileHashError } = this.props.uploaderProps;
|
||||
const { invoked, result } = safeInvoke(onFileHashError, error, ...args);
|
||||
|
||||
if (invoked) {
|
||||
return result;
|
||||
} else {
|
||||
// Just rethrow the error if no other onFileHashError was specified
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
onFileHashProgress(file, hashId, progress, ...args) {
|
||||
const { hashingFiles } = this.state;
|
||||
|
||||
// Note that if we've previously cleared our tracked hashing files before (because they've
|
||||
// previously succeeded or failed), the hashIds won't start from 0 to fill up the array from
|
||||
// the start. This is OK since Javascript's arrays are sparse, and the usual array
|
||||
// operations (besides basic `for` loop) will skip any holes.
|
||||
let updatedHashingFiles;
|
||||
if (hashingFiles[hashId]) {
|
||||
updatedHashingFiles = update(hashingFiles, {
|
||||
[hashId]: {
|
||||
progress: { $set: progress }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
updatedHashingFiles = update(hashingFiles, {
|
||||
[hashId]: {
|
||||
$set: { file, progress }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
hashingFiles: updatedHashingFiles
|
||||
});
|
||||
|
||||
const { onFileHashProgress } = this.props.uploaderProps;
|
||||
const { invoked, result } = safeInvoke(onFileHashProgress, file, hashId, progress, ...args);
|
||||
|
||||
return invoked ? result : undefined;
|
||||
},
|
||||
|
||||
onFileHashSuccess(files, ...args) {
|
||||
// Clear our tracked hashing files since they've succeeded
|
||||
this.setState({
|
||||
hashingFiles: []
|
||||
});
|
||||
|
||||
const { onFileHashSuccess } = this.props.uploaderProps;
|
||||
const { invoked, result } = safeInvoke(onFileHashSuccess, files, ...args);
|
||||
|
||||
return invoked ? result : files;
|
||||
},
|
||||
|
||||
onManualRetry(...args) {
|
||||
this.setState({
|
||||
manualRetryAttempt: this.state.manualRetryAttempt + 1
|
||||
});
|
||||
|
||||
safeInvoke(this.props.uploaderProps.onManualRetry, ...args);
|
||||
},
|
||||
|
||||
onUpload(...args) {
|
||||
if (!this.state.uploadInProgress) {
|
||||
this.setState({ uploadInProgress: true });
|
||||
}
|
||||
|
||||
safeInvoke(this.props.uploaderProps.onUpload, ...args);
|
||||
},
|
||||
|
||||
render() {
|
||||
const {
|
||||
areAssetsEditable,
|
||||
className,
|
||||
disabled: isDisabled,
|
||||
uploaderProps,
|
||||
uploadMethod: method,
|
||||
...restProps
|
||||
} = this.props;
|
||||
const { uploadInProgress, errorClass } = this.state;
|
||||
|
||||
const uploadMethod = method === UploadMethods.USE_URL_PARAM
|
||||
// If `upload_method` isn't in the current query parameters, tell the UI that we want
|
||||
// it to control which method gets used by setting the url parameter
|
||||
? getCurrentQueryParams().uploadMethod || UploadMethods.USE_URL_PARAM
|
||||
: method;
|
||||
|
||||
const disabled = isDisabled || uploadInProgress || errorClass || !uploadMethod;
|
||||
|
||||
const props = {
|
||||
...restProps,
|
||||
|
||||
className,
|
||||
uploadMethod,
|
||||
areAssetsEditable: !isDisabled && areAssetsEditable,
|
||||
|
||||
// Only show the error state once all files are finished
|
||||
showError: !uploadInProgress && errorClass,
|
||||
|
||||
uploaderProps: {
|
||||
...uploaderProps,
|
||||
hashLocally: uploadMethod === UploadMethods.HASH,
|
||||
onAllComplete: this.onAllComplete,
|
||||
onError: this.onError,
|
||||
onFileHashError: this.onFileHashError,
|
||||
onFileHashProgress: this.onFileHashProgress,
|
||||
onFileHashSuccess: this.onFileHashSuccess,
|
||||
onManualRetry: this.onManualRetry,
|
||||
onUpload: this.onUpload,
|
||||
showErrorNotification: this.showErrorNotification
|
||||
}
|
||||
};
|
||||
|
||||
// All props meant for UploadFileDialogUI will be passed through by
|
||||
// UploadDragAndDropArea.
|
||||
return (
|
||||
<UploadDragAndDropArea ref="uploader" {...props} disabled={disabled}>
|
||||
<UploadFileDialogUI {...this.state} />
|
||||
</UploadDragAndDropArea>
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
export default UploadFileDialog;
|
@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ErrorClasses } from '../../../../constants/error_constants';
|
||||
|
||||
import { getLangText } from '../../../../utils/lang';
|
||||
|
||||
|
||||
const { arrayOf, bool, func, node, number, object, shape, string } = React.PropTypes;
|
||||
|
||||
/** HELPER COMPONENTS **/
|
||||
// Retry button
|
||||
const UploadErrorRetryButton = ({ children, handleRetryFiles, openIntercom }) => (
|
||||
<button
|
||||
className='btn btn-default'
|
||||
onClick={() => {
|
||||
if (openIntercom) {
|
||||
window.Intercom('showNewMessage', getLangText("I'm having trouble uploading my file."));
|
||||
}
|
||||
|
||||
handleRetryFiles();
|
||||
}}
|
||||
type="button">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
UploadErrorRetryButton.propTypes = {
|
||||
handleRetryFiles: func.isRequired,
|
||||
|
||||
children: node,
|
||||
openIntercom: bool
|
||||
};
|
||||
|
||||
// Contact us dialog
|
||||
const UploadErrorContactUs = ({ handleRetryFiles }) => (
|
||||
<div className='file-drag-and-drop-error'>
|
||||
<h4>{getLangText('Let us help you')}</h4>
|
||||
<p>{getLangText('Still having problems? Send us a message.')}</p>
|
||||
<UploadErrorRetryButton openIntercom handleRetryFiles={handleRetryFiles}>
|
||||
{getLangText('Contact us')}
|
||||
</UploadErrorRetryButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
UploadErrorContactUs.propTypes = {
|
||||
handleRetryFiles: func.isRequired
|
||||
};
|
||||
|
||||
// Error details dialog
|
||||
const UploadErrorDetails = ({ errorClass: { prettifiedText }, failedFiles, handleRetryFiles }) => (
|
||||
<div className='file-drag-and-drop-error'>
|
||||
<div
|
||||
className={classNames(
|
||||
'file-drag-and-drop-error-detail', {
|
||||
'file-drag-and-drop-error-detail-multiple-files': failedFiles.length
|
||||
}
|
||||
)}>
|
||||
<h4>{getLangText(failedFiles.length ? 'Some files did not upload correctly'
|
||||
: 'Error uploading the file!')}
|
||||
</h4>
|
||||
<p>{prettifiedText}</p>
|
||||
<UploadErrorRetryButton handleRetryFiles={handleRetryFiles}>
|
||||
{getLangText('Retry')}
|
||||
</UploadErrorRetryButton>
|
||||
</div>
|
||||
<span
|
||||
className={classNames(
|
||||
'file-drag-and-drop-error-icon-container', {
|
||||
'file-drag-and-drop-error-icon-container-multiple-files': failedFiles.length
|
||||
}
|
||||
)}>
|
||||
<span className='ascribe-icon icon-ascribe-thin-cross'></span>
|
||||
</span>
|
||||
<div className='file-drag-and-drop-error-file-names'>
|
||||
<ul>
|
||||
{failedFiles.map(({ id, originalName }) => (
|
||||
<li key={id} className='file-name'>{originalName}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
UploadErrorDetails.propTypes = {
|
||||
errorClass: shape({
|
||||
prettifiedText: string.isRequired
|
||||
}).isRequired,
|
||||
failedFiles: arrayOf(shape({
|
||||
id: number.isRequired,
|
||||
originalName: string.isRequired
|
||||
})).isRequired,
|
||||
handleRetryFiles: func.isRequired
|
||||
};
|
||||
|
||||
|
||||
/** CONTAINER COMPONENT **/
|
||||
// Displays an error detail dialog or a contact us dialog depending on the type of upload error
|
||||
// encountered.
|
||||
const UploadFileDialogErrorHandler = ({ errorClass, failedFiles, handleRetryFile, ...props }) => {
|
||||
// Just go through and retry all the files if the user wants to retry
|
||||
const handleRetryFiles = () => {
|
||||
failedFiles.forEach(handleRetryFile);
|
||||
};
|
||||
|
||||
const dialogProps = {
|
||||
...props,
|
||||
errorClass,
|
||||
failedFiles,
|
||||
handleRetryFiles
|
||||
};
|
||||
|
||||
return errorClass.name === ErrorClasses.upload.contactUs.name
|
||||
? (<UploadErrorContactUs {...dialogProps} />)
|
||||
: (<UploadErrorDetails {...dialogProps} />);
|
||||
};
|
||||
|
||||
UploadFileDialogErrorHandler.propTypes = {
|
||||
errorClass: shape({
|
||||
name: string
|
||||
}).isRequired,
|
||||
failedFiles: arrayOf(object).isRequired,
|
||||
handleRetryFile: func.isRequired
|
||||
};
|
||||
|
||||
export default UploadFileDialogErrorHandler;
|
@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
|
||||
import { dragAndDropAvailable } from 'js-utility-belt/es6/feature_detection';
|
||||
|
||||
import UploadFileDialogPrintWrapper from './upload_file_dialog_print_wrapper';
|
||||
|
||||
import { UploadMethods } from '../../../../constants/uploader_constants';
|
||||
|
||||
import { getLangText } from '../../../../utils/lang';
|
||||
|
||||
|
||||
const { bool, func, oneOf, shape, string } = React.PropTypes;
|
||||
|
||||
const propTypes = {
|
||||
fileTypeNames: shape({
|
||||
plural: string.isRequired,
|
||||
singular: string.isRequired
|
||||
}).isRequired,
|
||||
uploadMethod: oneOf([
|
||||
UploadMethods.HASH,
|
||||
UploadMethods.NORMAL,
|
||||
UploadMethods.USE_URL_PARAM
|
||||
]).isRequired,
|
||||
|
||||
handleSelectFiles: func,
|
||||
multiple: bool
|
||||
};
|
||||
|
||||
const getDragDialog = (fileTypeName) => {
|
||||
if (dragAndDropAvailable) {
|
||||
return [
|
||||
<p key="upload-drag-title" className="file-drag-and-drop-dialog-title">
|
||||
{getLangText('Drag %s here', fileTypeName)}
|
||||
</p>,
|
||||
<p key="upload-drag-subtitle">{getLangText('or')}</p>
|
||||
];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const UploadFileDialogFileSelector = ({ fileTypeNames, handleSelectFiles, multiple, uploadMethod }) => {
|
||||
const uploadMethodName = uploadMethod === UploadMethods.HASH ? 'hash' : 'upload';
|
||||
|
||||
if (uploadMethod !== UploadMethods.HASH || uploadMethod !== UploadMethods.NORMAL) {
|
||||
console.logGlobal(
|
||||
new Error('Unsupported upload method given to UploadFileSelector'),
|
||||
{ uploadMethod }
|
||||
);
|
||||
}
|
||||
|
||||
const buttonMsg = multiple
|
||||
? getLangText(`choose %s to ${uploadMethodName}`, fileTypeNames.plural)
|
||||
: getLangText(`choose a %s to ${uploadMethodName}`, fileTypeNames.singular);
|
||||
|
||||
return (
|
||||
<div className="file-drag-and-drop-dialog">
|
||||
{getDragDialog(multiple ? fileTypeNames.plural : fileTypeNames.singular)}
|
||||
<button
|
||||
className="btn btn-default"
|
||||
onClick={handleSelectFiles}>
|
||||
{buttonMsg}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UploadFileDialogFileSelector.propTypes = propTypes;
|
||||
|
||||
export default UploadFileDialogPrintWrapper(UploadFileDialogFileSelector);
|
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import ProgressBar from 'react-bootstrap/lib/ProgressBar';
|
||||
|
||||
import UploadFileDialogPrintWrapper from './upload_file_dialog_print_wrapper';
|
||||
|
||||
import { getLangText } from '../../../../utils/lang';
|
||||
|
||||
|
||||
const { arrayOf, func, number, shape } = React.PropTypes;
|
||||
|
||||
const propTypes = {
|
||||
handleCancelHashing: func.isRequired,
|
||||
hashingFiles: arrayOf(shape({
|
||||
progress: number.isRequired
|
||||
})).isRequired
|
||||
};
|
||||
|
||||
const UploadFileDialogHashProgress = ({ handleCancelHashing, hashingFiles }) => {
|
||||
const hashingProgress = 100 * hashingFiles
|
||||
.reduce((total, { progress }) => total + (progress / hashingFiles.length), 0);
|
||||
|
||||
return (
|
||||
<div className="file-drag-and-drop-hashing-dialog">
|
||||
<p>
|
||||
{getLangText(`Computing ${hashingFiles.length > 1 ? 'hashes' : 'hash'}... ` +
|
||||
'This may take a few minutes.')}
|
||||
</p>
|
||||
<a onClick={handleCancelHashing} tabIndex={0}>
|
||||
{getLangText('Cancel hashing')}
|
||||
</a>
|
||||
<ProgressBar
|
||||
className="ascribe-progress-bar"
|
||||
label="%(percent)s%"
|
||||
now={Math.ceil(hashingProgress)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UploadFileDialogHashProgress.propTypes = propTypes;
|
||||
|
||||
export default UploadFileDialogPrintWrapper(UploadFileDialogHashProgress);
|
@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import Link from 'react-router/es6/Link';
|
||||
|
||||
import UploadFileDialogPrintWrapper from './upload_file_dialog_print_wrapper';
|
||||
|
||||
import withContext from '../../../context/with_context';
|
||||
|
||||
import { locationShape } from '../../../prop_types';
|
||||
|
||||
import { UploadMethods } from '../../../../constants/uploader_constants';
|
||||
|
||||
import { getLangText } from '../../../../utils/lang';
|
||||
import { getCurrentQueryParams } from '../../../../utils/url';
|
||||
|
||||
|
||||
const propTypes = {
|
||||
location: locationShape.isRequired
|
||||
};
|
||||
|
||||
const UploadFileDialogMethodSelector = ({ location }) => {
|
||||
const currentQueryParams = getCurrentQueryParams();
|
||||
const queryParamsHash = Object.assign({}, currentQueryParams, {
|
||||
uploadMethod: UploadMethods.HASH
|
||||
});
|
||||
const queryParamsNormal = Object.assign({}, currentQueryParams, {
|
||||
uploadMethod: UploadMethods.NORMAL
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="file-drag-and-drop-dialog">
|
||||
<div className="present-options">
|
||||
<p className="file-drag-and-drop-dialog-title">
|
||||
{getLangText('Would you rather')}
|
||||
</p>
|
||||
<Link query={queryParamsHash} to={location.pathname}>
|
||||
<button className="btn btn-default btn-sm">
|
||||
{getLangText('Hash your work')}
|
||||
</button>
|
||||
</Link>
|
||||
<span>{getLangText('or')}</span>
|
||||
<Link query={queryParamsNormal} to={location.pathname}>
|
||||
<button className="btn btn-default btn-sm">
|
||||
{getLangText('Upload and hash your work')}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UploadFileDialogMethodSelector.propTypes = propTypes;
|
||||
|
||||
export default UploadFileDialogPrintWrapper(withContext(UploadFileDialogMethodSelector, 'location'));
|
@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
|
||||
import UploadFilePreview from '../upload_file_preview';
|
||||
|
||||
|
||||
const { arrayOf, func, object } = React.PropTypes;
|
||||
|
||||
const propTypes = {
|
||||
files: arrayOf(object).isRequired,
|
||||
|
||||
// Mapping of file ids to thumbnail urls for the file previews.
|
||||
// If the file already has a `thumbnailUrl` property, prefer that over checking this mapping.
|
||||
thumbnailMapping: object,
|
||||
|
||||
// Props used by UploadFilePreview
|
||||
handleCancelFile: func.isRequired,
|
||||
handleDeleteFile: func.isRequired,
|
||||
handlePauseFile: func.isRequired,
|
||||
handleResumeFile: func.isRequired,
|
||||
|
||||
// All other props are passed down to UploadFilePreviews, including:
|
||||
// * downloadable: enable files to be downloaded
|
||||
// * pausable: enable pause / resume functionality
|
||||
// * removable: enable files to be removed
|
||||
};
|
||||
|
||||
const UploadFileDialogPreviewIterator = ({ files, thumbnailMapping, ...props }) => {
|
||||
if (files.length) {
|
||||
const multipleFiles = files.length > 1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{files.map((file, i) => {
|
||||
// Try to use an id from the file, but if we can't find one, just use its array
|
||||
// index.
|
||||
const key = file.uuid || file.id || i;
|
||||
|
||||
const thumbnailUrl = file.thumbnailUrl ||
|
||||
(thumbnailMapping && 'id' in file ? thumbnailMapping[file.id] : null);
|
||||
|
||||
return (
|
||||
<UploadFilePreview
|
||||
key={key}
|
||||
{...props}
|
||||
file={file}
|
||||
showName={!multipleFiles}
|
||||
showProgress={multipleFiles}
|
||||
thumbnailUrl={thumbnailUrl} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
UploadFileDialogPreviewIterator.propTypes = propTypes;
|
||||
|
||||
export default UploadFileDialogPreviewIterator;
|
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getLangText } from '../../../../utils/lang';
|
||||
|
||||
|
||||
/**
|
||||
* Wrapper component to hide the given upload file dialog component from being printed, instead
|
||||
* replacing it with a message saying that there's nothing uploaded yet. Useful for UI components
|
||||
* that are used to show a pre-upload state of the upload file dialog.
|
||||
*/
|
||||
const UploadFileDialogPrintWrapper = (Component) => (
|
||||
(props) => (
|
||||
<div>
|
||||
<div className="hidden-print">
|
||||
<Component {...props} />
|
||||
</div>
|
||||
<p className="text-align-center visible-print">
|
||||
{getLangText('No files uploaded')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export default UploadFileDialogPrintWrapper;
|
@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { failedFilesFilter, validProgressFilesFilter } from 'react-utility-belt/es6/uploader/utils/file_filters';
|
||||
|
||||
import UploadFileDialogErrorHandler from './upload_file_dialog_error_handler';
|
||||
import UploadFileDialogFileSelector from './upload_file_dialog_file_selector';
|
||||
import UploadFileDialogHashProgress from './upload_file_dialog_hash_progress';
|
||||
import UploadFileDialogMethodSelector from './upload_file_dialog_method_selector';
|
||||
import UploadFileDialogPreviewIterator from './upload_file_dialog_preview_iterator';
|
||||
|
||||
import UploadProgressBar from '../upload_progress_bar';
|
||||
|
||||
import { UploadMethods } from '../../../../constants/uploader_constants';
|
||||
|
||||
|
||||
const { arrayOf, bool, func, number, object, oneOf, shape, string } = React.PropTypes;
|
||||
|
||||
const propTypes = {
|
||||
/**
|
||||
* Upload method being used by the uploader.
|
||||
*
|
||||
* If USE_URL_PARAM is used, assume that the upload method is being determined by the url
|
||||
* parameters and show a selector that will modify the current url's query parameters to be
|
||||
* one of the available methods.
|
||||
*/
|
||||
uploadMethod: oneOf([
|
||||
UploadMethods.HASH,
|
||||
UploadMethods.NORMAL,
|
||||
UploadMethods.USE_URL_PARAM
|
||||
]).isRequired,
|
||||
|
||||
areAssetsDownloadable: bool,
|
||||
areAssetsEditable: bool,
|
||||
disabled: bool,
|
||||
errorClass: object,
|
||||
fileTypeNames: shape({
|
||||
plural: string.isRequired,
|
||||
singular: string.isRequired
|
||||
}),
|
||||
hashingFiles: arrayOf(shape({
|
||||
progress: number.isRequired
|
||||
})),
|
||||
showError: bool,
|
||||
thumbnailMapping: object,
|
||||
|
||||
// Provided by ReactS3FineUploader
|
||||
// eslint-disable-next-line react/sort-prop-types
|
||||
uploaderFiles: arrayOf(object).isRequired,
|
||||
multiple: bool
|
||||
|
||||
// All other props are passed through to the child components
|
||||
};
|
||||
|
||||
const contextTypes = {
|
||||
handleCancelFile: func.isRequired,
|
||||
handleCancelHashing: func.isRequired,
|
||||
handleDeleteFile: func.isRequired,
|
||||
handlePauseFile: func.isRequired,
|
||||
handleResumeFile: func.isRequired,
|
||||
handleRetryFile: func.isRequired,
|
||||
handleSelectFiles: func.isRequired
|
||||
};
|
||||
|
||||
const UploadFileDialogUI = ({
|
||||
areAssetsDownloadable,
|
||||
areAssetsEditable,
|
||||
disabled,
|
||||
errorClass,
|
||||
hashingFiles,
|
||||
multiple,
|
||||
showError,
|
||||
thumbnailMapping,
|
||||
uploaderFiles,
|
||||
uploadMethod,
|
||||
...props
|
||||
}, {
|
||||
handleCancelFile,
|
||||
handleCancelHashing,
|
||||
handleDeleteFile,
|
||||
handlePauseFile,
|
||||
handleResumeFile,
|
||||
handleRetryFile,
|
||||
handleSelectFiles
|
||||
}) => {
|
||||
let uploaderUI;
|
||||
if (uploadMethod === UploadMethods.USE_URL_PARAMS) {
|
||||
// Show upload method selector that will change the current url's query parameters
|
||||
uploaderUI = (
|
||||
<UploadFileDialogMethodSelector uploadMethod={UploadMethods.USE_URL_PARAMS} />
|
||||
);
|
||||
} else if (Array.isArray(hashingFiles) && hashingFiles.length) {
|
||||
uploaderUI = (
|
||||
<UploadFileDialogHashProgress
|
||||
handleCancelHashing={handleCancelHashing}
|
||||
hashingFiles={hashingFiles} />
|
||||
);
|
||||
} else {
|
||||
const failedFiles = uploaderFiles.filter(failedFilesFilter);
|
||||
const validFiles = uploaderFiles.filter(validProgressFilesFilter);
|
||||
|
||||
if (errorClass && showError && failedFiles.length) {
|
||||
uploaderUI = (
|
||||
<UploadFileDialogErrorHandler
|
||||
errorClass={errorClass}
|
||||
failedFiles={failedFiles}
|
||||
handleRetryFile={handleRetryFile} />
|
||||
);
|
||||
} else if (validFiles.length) {
|
||||
uploaderUI = [(
|
||||
<UploadFileDialogPreviewIterator
|
||||
key="upload-file-preview-iterator"
|
||||
downloadable={areAssetsDownloadable}
|
||||
files={validFiles}
|
||||
handleCancelFile={handleCancelFile}
|
||||
handleDeleteFile={handleDeleteFile}
|
||||
handlePauseFile={handlePauseFile}
|
||||
handleResumeFile={handleResumeFile}
|
||||
removable={areAssetsEditable}
|
||||
thumbnailMapping={thumbnailMapping} />
|
||||
), (
|
||||
<UploadProgressBar
|
||||
key="upload-progress"
|
||||
className="file-drag-and-drop-overall-progress-bar"
|
||||
files={uploaderFiles} />
|
||||
)];
|
||||
} else {
|
||||
uploaderUI = (
|
||||
<UploadFileDialogFileSelector
|
||||
handleSelectFiles={handleSelectFiles}
|
||||
uploadMethod={uploadMethod} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('file-drag-and-drop', { 'inactive-dropzone': disabled })}>
|
||||
{uploaderUI}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UploadFileDialogUI.propTypes = propTypes;
|
||||
UploadFileDialogUI.contextTypes = contextTypes;
|
||||
|
||||
export default UploadFileDialogUI;
|
@ -0,0 +1,3 @@
|
||||
// Make it easier for users to import this component by default exporting the container in an
|
||||
// index.js
|
||||
export { default } from './upload_file_preview';
|
@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { pausedFilesFilter, uploadedFilesFilter, uploadingFilesFilter } from 'react-utility-belt/es6/uploader/utils/file_filters';
|
||||
|
||||
import UploadFilePreviewImage from './upload_file_preview_image';
|
||||
import UploadFilePreviewOther from './upload_file_preview_other';
|
||||
|
||||
import UploadProgressBar from '../upload_progress_bar';
|
||||
|
||||
import { extractFileExtensionFromString } from '../../../../utils/file';
|
||||
import { getLangText } from '../../../../utils/lang';
|
||||
import { truncateText } from '../../../../utils/text';
|
||||
|
||||
|
||||
const { bool, func, shape, string } = React.PropTypes;
|
||||
|
||||
const UploadFileName = ({ file: { name } }) => (
|
||||
<span className="upload-file-preview--label">
|
||||
{truncateText(name, 30, `(...).${extractFileExtensionFromString(name)}`)}
|
||||
</span>
|
||||
);
|
||||
|
||||
UploadFileName.propTypes = {
|
||||
file: shape({
|
||||
name: string.isRequired
|
||||
}).isRequired
|
||||
};
|
||||
|
||||
const UploadRemoveButton = ({ handleRemoveFile }) => (
|
||||
<button className="remove-file-btn text-center" onClick={handleRemoveFile}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="glyphicon glyphicon-remove"
|
||||
title={getLangText('Remove file')} />
|
||||
</button>
|
||||
);
|
||||
|
||||
UploadRemoveButton.propTypes = {
|
||||
handleRemoveFile: func.isRequired
|
||||
};
|
||||
|
||||
|
||||
const UploadFilePreview = React.createClass({
|
||||
propTypes: {
|
||||
file: shape({
|
||||
name: string.isRequired,
|
||||
type: string.isRequired
|
||||
}).isRequired,
|
||||
handleCancelFile: func.isRequired,
|
||||
handleDeleteFile: func.isRequired,
|
||||
handlePauseFile: func.isRequired,
|
||||
handleResumeFile: func.isRequired,
|
||||
|
||||
className: string,
|
||||
downloadable: bool,
|
||||
pausable: bool,
|
||||
removable: bool,
|
||||
showName: bool,
|
||||
showProgress: bool,
|
||||
thumbnailUrl: string
|
||||
},
|
||||
|
||||
handleRemoveFile() {
|
||||
const { file, handleCancelFile, handleDeleteFile } = this.props;
|
||||
|
||||
// We only want to delete when we're sure that the file has been *completely* uploaded to S3
|
||||
// and can now be properly deleted using an HTTP DELETE request.
|
||||
if (uploadedFilesFilter(file) && file.s3UrlSafe) {
|
||||
handleDeleteFile(file);
|
||||
} else {
|
||||
handleCancelFile(file);
|
||||
}
|
||||
},
|
||||
|
||||
toggleUploadProcess() {
|
||||
const { file, handlePauseFile, handleResumeFile } = this.props;
|
||||
|
||||
if (uploadingFilesFilter(file)) {
|
||||
handlePauseFile(file.id);
|
||||
} else if (pausedFilesFilter(file)) {
|
||||
handleResumeFile(file.id);
|
||||
} else {
|
||||
console.logGlobal(
|
||||
new Error('Tried to pause / resume upload of file that was not in a paused or ' +
|
||||
'uploading state'),
|
||||
{ file }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
downloadable,
|
||||
file,
|
||||
pausable,
|
||||
removable,
|
||||
showName,
|
||||
showProgress,
|
||||
thumbnailUrl
|
||||
} = this.props;
|
||||
|
||||
const previewProps = {
|
||||
downloadable,
|
||||
file,
|
||||
pausable,
|
||||
toggleUploadProcess: this.toggleUploadProcess
|
||||
};
|
||||
|
||||
// Decide whether an image or a placeholder thumbnail should be displayed
|
||||
// Even if a file is not an image, we'll display it as an image if it has has a thumbnail
|
||||
const previewElement = (thumbnailUrl || file.type.split('/')[0] === 'image')
|
||||
? (<UploadFilePreviewImage {...previewProps} thumbnailUrl={thumbnailUrl || file.url} />)
|
||||
: (<UploadFilePreviewOther {...previewProps} />);
|
||||
|
||||
const fileProgressBar = showProgress ? (
|
||||
<UploadProgressBar className="ascribe-progress-bar-xs" files={[file]} />
|
||||
) : null;
|
||||
|
||||
const fileRemoveButton = removable ? (
|
||||
<UploadRemoveButton handleRemoveFile={this.handleRemoveFile} />
|
||||
) : null;
|
||||
|
||||
const fileName = showName ? (<UploadFileName file={file} />) : null;
|
||||
|
||||
return (
|
||||
<div className={classNames(className, 'upload-file-preview-container')}>
|
||||
<div className="upload-file-preview">
|
||||
{fileProgressBar}
|
||||
{previewElement}
|
||||
{fileRemoveButton}
|
||||
</div>
|
||||
{fileName}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default UploadFilePreview;
|
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import UploadFilePreviewTypeWrapper from './upload_file_preview_type_wrapper';
|
||||
|
||||
|
||||
const { node, string } = React.PropTypes;
|
||||
|
||||
const propTypes = {
|
||||
thumbnailUrl: string.isRequired,
|
||||
|
||||
children: node
|
||||
};
|
||||
|
||||
const UploadFilePreviewImage = ({ children, thumbnailUrl }) => {
|
||||
const imageStyle = {
|
||||
backgroundImage: `url("${thumbnailUrl}")`,
|
||||
backgroundSize: 'cover'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-drag-and-drop-preview-image hidden-print" style={imageStyle}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UploadFilePreviewImage.propTypes = propTypes;
|
||||
|
||||
export default UploadFilePreviewTypeWrapper(UploadFilePreviewImage);
|
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import UploadFilePreviewTypeWrapper from './upload_file_preview_type_wrapper';
|
||||
|
||||
import { extractFileExtensionFromString } from '../../../../utils/file';
|
||||
|
||||
|
||||
const { bool, node, shape, string } = React.PropTypes;
|
||||
|
||||
const propTypes = {
|
||||
file: shape({
|
||||
name: string.isRequired
|
||||
}).isRequired,
|
||||
|
||||
children: node,
|
||||
showType: bool
|
||||
};
|
||||
|
||||
const UploadFilePreviewOther = ({ children, showType, file: { name } }) => (
|
||||
<div className="file-drag-and-drop-preview-other">
|
||||
{children}
|
||||
{showType ? (
|
||||
<p className="file-drag-and-drop-preview-other--label">
|
||||
{`.${extractFileExtensionFromString(name) || 'file'}`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
UploadFilePreviewOther.propTypes = propTypes;
|
||||
|
||||
export default UploadFilePreviewTypeWrapper(UploadFilePreviewOther);
|
@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
|
||||
import { pausedFilesFilter, uploadedFilesFilter, uploadingFilesFilter } from 'react-utility-belt/es6/uploader/utils/file_filters';
|
||||
|
||||
import AscribeSpinner from '../../../ascribe_spinner';
|
||||
|
||||
import { getLangText } from '../../../../utils/lang';
|
||||
|
||||
|
||||
const { bool, func, shape, string } = React.PropTypes;
|
||||
|
||||
/**
|
||||
* Wrapper component for preview types that handles upload states and pause / resume functionality
|
||||
*/
|
||||
const displayName = 'UploadFilePreviewTypeWrapper';
|
||||
const propTypes = {
|
||||
file: shape({
|
||||
s3UrlSafe: string.isRequired
|
||||
}).isRequired,
|
||||
|
||||
// Allow the file to be downloaded
|
||||
downloadable: bool,
|
||||
|
||||
// Enable upload pause / resume functionality
|
||||
pausable: bool,
|
||||
|
||||
// Controls pausing / resuming the file if pause / resume functionality is enabled
|
||||
toggleUploadProcess: func
|
||||
|
||||
// All props are passed through to the PreviewComponent as well
|
||||
};
|
||||
|
||||
const UploadFilePreviewTypeWrapper = (PreviewComponent) => {
|
||||
const wrapperComponent = (props) => {
|
||||
const { downloadable, file, pausable, toggleUploadProcess } = props;
|
||||
const { s3UrlSafe } = file;
|
||||
let uploadStateSymbol;
|
||||
|
||||
if (uploadedFilesFilter(file)) {
|
||||
// Uploaded
|
||||
uploadStateSymbol = downloadable ? (
|
||||
<a
|
||||
aria-hidden
|
||||
className="glyphicon glyphicon-download action-file"
|
||||
href={s3UrlSafe}
|
||||
target="_blank"
|
||||
title={getLangText('Download file')} />
|
||||
) : (
|
||||
<span className='ascribe-icon icon-ascribe-ok action-file' />
|
||||
);
|
||||
} else if (uploadingFilesFilter(file)) {
|
||||
// Uploading
|
||||
uploadStateSymbol = pausable ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="glyphicon glyphicon-pause action-file"
|
||||
onClick={toggleUploadProcess}
|
||||
title={getLangText('Pause upload')} />
|
||||
) : (
|
||||
<AscribeSpinner className="spinner-file" color='dark-blue' size='md' />
|
||||
);
|
||||
} else if (pausedFilesFilter(file)) {
|
||||
// Paused
|
||||
if (pausable) {
|
||||
uploadStateSymbol = (
|
||||
<span
|
||||
aria-hidden
|
||||
className="glyphicon glyphicon-play action-file"
|
||||
onClick={toggleUploadProcess}
|
||||
title={getLangText('Resume uploading')} />
|
||||
);
|
||||
} else {
|
||||
console.logGlobal(
|
||||
new Error('UploadFilePreview encountered paused file but did not have resume ' +
|
||||
'functionality enabled'),
|
||||
{ file }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.logGlobal(
|
||||
new Error('UploadFilePreview encountered file that was not uploading, uploaded, ' +
|
||||
'or paused'),
|
||||
{ file }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewComponent {...props} file={file}>
|
||||
{uploadStateSymbol}
|
||||
</PreviewComponent>
|
||||
);
|
||||
};
|
||||
|
||||
wrapperComponent.displayName = displayName;
|
||||
wrapperComponent.propTypes = propTypes;
|
||||
|
||||
return wrapperComponent;
|
||||
};
|
||||
|
||||
export default UploadFilePreviewTypeWrapper;
|
46
js/components/ascribe_uploader/ui/upload_progress_bar.js
Normal file
46
js/components/ascribe_uploader/ui/upload_progress_bar.js
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ProgressBar from 'react-bootstrap/lib/ProgressBar';
|
||||
|
||||
|
||||
const { arrayOf, number, shape, string } = React.PropTypes;
|
||||
|
||||
function calcOverallFileSize(files) {
|
||||
// We just sum up all files' sizes
|
||||
return files.reduce((overallSize, { size }) => overallSize + size, 0);
|
||||
}
|
||||
|
||||
function calcOverallProgress(files) {
|
||||
const overallFileSize = calcOverallFileSize(files);
|
||||
|
||||
// We calculate the overall progress by summing the individuals files' progresses in relation
|
||||
// to the total size of all uploads
|
||||
return files.reduce((overallProgress, { progress, size }) => (
|
||||
(size / overallFileSize) * progress
|
||||
), 0);
|
||||
}
|
||||
|
||||
const propTypes = {
|
||||
files: arrayOf(shape({
|
||||
progress: number.isRequired,
|
||||
size: number.isRequired
|
||||
})).isRequired,
|
||||
|
||||
className: string
|
||||
};
|
||||
|
||||
const UploadProgressBar = ({ className, files }) => {
|
||||
const overallProgress = Math.ceil(calcOverallProgress(files));
|
||||
|
||||
return (
|
||||
<ProgressBar
|
||||
className={classNames(className, 'ascribe-progress-bar', { 'hidden': !files.length })}
|
||||
label={`${overallProgress}%`}
|
||||
now={overallProgress} />
|
||||
);
|
||||
};
|
||||
|
||||
UploadProgressBar.propTypes = propTypes;
|
||||
|
||||
export default UploadProgressBar;
|
341
js/components/ascribe_uploader/uploader.js
Normal file
341
js/components/ascribe_uploader/uploader.js
Normal file
@ -0,0 +1,341 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AscribeFileHashUploaderFactory, AscribeUploaderFactory } from 'ascribe-react-components/es6/uploader/ascribe_uploader_factory';
|
||||
import { uploaderCreateBlobParamsShapeSpec } from 'ascribe-react-components/es6/prop_types/uploader_create_blob_params_shape';
|
||||
import { uploaderRequestKeyParamsShapeSpec } from 'ascribe-react-components/es6/prop_types/uploader_request_key_params_shape';
|
||||
import ValidationErrors from 'react-utility-belt/es6/uploader/constants/validation_errors';
|
||||
import uploaderSpecExtender from 'react-utility-belt/es6/uploader/utils/uploader_spec_extender';
|
||||
|
||||
import { safeInvoke } from 'js-utility-belt/es6';
|
||||
|
||||
import S3Fetcher from '../../fetchers/s3_fetcher';
|
||||
|
||||
import GlobalNotificationModel from '../../models/global_notification_model';
|
||||
import GlobalNotificationActions from '../../actions/global_notification_actions';
|
||||
|
||||
import { S3_ACCESS_KEY, S3_ACL, S3_BUCKET } from '../../constants/uploader_constants';
|
||||
|
||||
import { makeCsrfHeader as createCsrfHeader } from '../../utils/csrf';
|
||||
import { kbToMb } from '../../utils/file';
|
||||
import { getLangText } from '../../utils/lang';
|
||||
import request from '../../utils/request';
|
||||
import { resolveUrl } from '../../utils/url_resolver';
|
||||
|
||||
|
||||
const { bool, func, number, shape, string } = React.PropTypes;
|
||||
|
||||
const Uploader = React.createClass(uploaderSpecExtender({
|
||||
displayName: 'Uploader',
|
||||
|
||||
propTypes: {
|
||||
// Whether the selected files should be hashed locally before being uploaded
|
||||
hashLocally: bool,
|
||||
|
||||
/**
|
||||
* Override to control the notification (if any) that is shown when an upload fails.
|
||||
*
|
||||
* @param {File} file File that errored
|
||||
* @param {string} errorReason Reason for the error
|
||||
* @param {Xhr|Xdr} xhr The xhr used to make the request
|
||||
*/
|
||||
showErrorNotification: func,
|
||||
|
||||
// AscribeBlobUploader props
|
||||
// Extend the base createBlobParamsShape with piece details
|
||||
// eslint-disable-next-line react/sort-prop-types
|
||||
createBlobParams: shape({
|
||||
...uploaderCreateBlobParamsShapeSpec,
|
||||
body: shape({
|
||||
'piece_id': number
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
|
||||
onCreateBlobError: func,
|
||||
onCreateBlobSuccess: func,
|
||||
|
||||
// AscribeRequestKeyUploader props
|
||||
// Extend the base requestKeyParamsShape with category and piece details
|
||||
// eslint-disable-next-line react/sort-prop-types
|
||||
requestKeyParams: shape({
|
||||
...uploaderRequestKeyParamsShapeSpec,
|
||||
body: shape({
|
||||
'category': string.isRequired,
|
||||
'piece_id': number,
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
|
||||
onRequestKeyError: func,
|
||||
onRequestKeySuccess: func,
|
||||
|
||||
// All other props are passed through to AscribeUploader
|
||||
},
|
||||
|
||||
getDefaultProps() {
|
||||
return {
|
||||
onCreateBlobError: (err, file) => this.onError(file, err && err.message),
|
||||
onCreateBlobSuccess: this.defaultOnCreateBlobSuccess,
|
||||
onDeleteOnlineFile: this.defaultOnDeleteOnlineFile,
|
||||
onRequestKeyError: (err, file) => this.onError(file, err && err.message),
|
||||
onRequestKeySuccess: this.defaultOnRequestKeySuccess,
|
||||
onSessionRequestComplete: this.defaultOnSessionRequestComplete,
|
||||
showErrorNotification: (file, errorReason) => {
|
||||
const message = errorReason || getLangText('Oops, we had a problem uploading ' +
|
||||
'your file. Please contact us if this ' +
|
||||
'happens repeatedly.');
|
||||
|
||||
const notification = new GlobalNotificationModel(message, 'danger', 5000);
|
||||
GlobalNotificationActions.appendGlobalNotification(notification);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount() {
|
||||
// Create uploaders based on current app settings
|
||||
// Let's assume that these settings won't change without forcing a remounting of this
|
||||
// component.
|
||||
const [AscribeUploader, AscribeFileHashUploader] = [
|
||||
AscribeUploaderFactory, AscribeFileHashUploaderFactory
|
||||
].map((factory) => factory({
|
||||
createCsrfHeader,
|
||||
// Note that our request module already includes the CSRF token on every call, so we
|
||||
// don't have to add it to the blob and request key headers ourselves
|
||||
request,
|
||||
S3_ACCESS_KEY,
|
||||
S3_ACL,
|
||||
S3_BUCKET,
|
||||
Urls: {
|
||||
S3_DELETE: resolveUrl('s3_delete_file'),
|
||||
S3_SIGNATUURE: resolveUrl('s3_signature')
|
||||
}
|
||||
}));
|
||||
|
||||
// Cache uploaders on this component
|
||||
this.AscribeUploader = AscribeUploader;
|
||||
this.AscribeFileHashUploader = AscribeFileHashUploader;
|
||||
},
|
||||
|
||||
getXhrErrorComment(xhr) {
|
||||
return xhr && {
|
||||
response: xhr.response,
|
||||
url: xhr.responseURL,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText
|
||||
};
|
||||
},
|
||||
|
||||
/** DEFAULT EVENT HANDLERS (CAN BE OVERRIDDEN COMPLETELY THROUGH PROPS) **/
|
||||
defaultOnCreateBlobSuccess(res, file) {
|
||||
// `res` should contain one of these file types as a property
|
||||
const fileType = res.otherdata || res.digitalwork || res.contractblob || res.thumbnail;
|
||||
|
||||
if (!fileType) {
|
||||
const errorMsg = getLangText(
|
||||
'Could not find a s3 url as the download location of the file: %s',
|
||||
file.name
|
||||
);
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const changeSet = { $set: fileType.url_safe };
|
||||
return {
|
||||
s3Url: changeSet,
|
||||
s3UrlSafe: changeSet
|
||||
};
|
||||
},
|
||||
|
||||
defaultOnDeleteOnlineFile(file) {
|
||||
return S3Fetcher.deleteFile(file.s3Key, file.s3Bucket);
|
||||
},
|
||||
|
||||
defaultOnRequestKeySuccess(res) {
|
||||
return res.key;
|
||||
},
|
||||
|
||||
defaultOnSessionRequestComplete(response, success) {
|
||||
if (!success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
response.forEach((file) => {
|
||||
file.url = file.s3UrlSafe;
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/** EXTENDED EVENT HANDLERS (ADDS ADDITIONAL BEHAVIOUR TO CALLBACK) **/
|
||||
onCanceled(file, ...args) {
|
||||
const notification = new GlobalNotificationModel(
|
||||
getLangText('Upload of "%s" cancelled', file.name),
|
||||
'success',
|
||||
5000
|
||||
);
|
||||
GlobalNotificationActions.appendGlobalNotification(notification);
|
||||
|
||||
safeInvoke(this.props.onCanceled, file, ...args);
|
||||
},
|
||||
|
||||
onDeleteComplete(file, xhr, isError, ...args) {
|
||||
const notificationTemplate = isError ? 'There was an error deleting "${name}"'
|
||||
: '"${name}" deleted';
|
||||
|
||||
const notification = new GlobalNotificationModel(
|
||||
getLangText(notificationTemplate, file),
|
||||
isError ? 'danger' : 'success',
|
||||
5000
|
||||
);
|
||||
|
||||
GlobalNotificationActions.appendGlobalNotification(notification);
|
||||
|
||||
safeInvoke(this.props.onDeleteComplete, file, xhr, isError, ...args);
|
||||
},
|
||||
|
||||
onError(file, errorReason, xhr, ...args) {
|
||||
const { onError, showErrorNotification } = this.props;
|
||||
const { uploader } = this.refs;
|
||||
|
||||
console.logGlobal(errorReason, {
|
||||
files: uploader.getFiles(),
|
||||
chunks: uploader.getChunks(),
|
||||
xhr: this.getXhrErrorComment(xhr)
|
||||
});
|
||||
|
||||
safeInvoke(showErrorNotification, file, errorReason, xhr, ...args);
|
||||
safeInvoke(onError, file, errorReason, xhr, ...args);
|
||||
},
|
||||
|
||||
onFileHashError(error, ...args) {
|
||||
let notification;
|
||||
|
||||
if (error && error.message && error.message.toLowerCase().includes('cancel')) {
|
||||
notification = new GlobalNotificationModel(error.message, 'success', 5000);
|
||||
} else {
|
||||
notification = new GlobalNotificationModel(
|
||||
'Failed to hash files. Please contact us if this problem persists.',
|
||||
'danger',
|
||||
5000
|
||||
);
|
||||
}
|
||||
|
||||
GlobalNotificationActions.appendGlobalNotification(notification);
|
||||
|
||||
const {
|
||||
invoked,
|
||||
result: errorResult
|
||||
} = safeInvoke(this.props.onFileHashError, error, ...args);
|
||||
|
||||
// If `onFileHashError` doesn't throw its own error or isn't invoked, rethrow the original
|
||||
// error back to the uploader
|
||||
if (invoked) {
|
||||
return errorResult;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
onValidationError(errors, validFiles, ...args) {
|
||||
const numFileLimitErrors = errors
|
||||
.filter((error) => error.validationError.type === ValidationErrors.FILE_LIMIT)
|
||||
.length;
|
||||
|
||||
if (numFileLimitErrors === errors.length) {
|
||||
// Validation failed because number of files submitted was over the limit
|
||||
const { limit, remaining } = errors[0].validationError.description;
|
||||
let notification;
|
||||
|
||||
// If we are currently under the limit, just select as many files from the files as
|
||||
// possible
|
||||
if (remaining) {
|
||||
const firstSubmittedFiles = errors
|
||||
.slice(0, remaining)
|
||||
.map((error) => error.file);
|
||||
|
||||
validFiles.push(...firstSubmittedFiles);
|
||||
|
||||
notification = new GlobalNotificationModel(
|
||||
getLangText(
|
||||
'Only %s were allowed (took first %s)',
|
||||
`${remaining} ${getLangText(remaining === 1 ? 'file' : 'files')}`,
|
||||
remaining
|
||||
),
|
||||
'danger',
|
||||
10000
|
||||
);
|
||||
} else {
|
||||
notification = new GlobalNotificationModel(
|
||||
getLangText(
|
||||
"Oops, you've already uploaded the maximum number of items (%s)!",
|
||||
limit
|
||||
),
|
||||
'danger',
|
||||
10000
|
||||
);
|
||||
}
|
||||
|
||||
GlobalNotificationActions.appendGlobalNotification(notification);
|
||||
} else {
|
||||
// Validation failed because of only some items; ignore those and notify the user
|
||||
// If a lot of submitted files fail validation, this might be spammy
|
||||
errors
|
||||
.map(({ file, validationError: { description, type } }) => {
|
||||
switch (type) {
|
||||
case ValidationErrors.SIZE:
|
||||
// eslint-disable-next-line max-len
|
||||
return getLangText('Cancelled upload of "${name}" as it was bigger than ${size}MB', {
|
||||
name: file.name,
|
||||
size: kbToMb(description.limit)
|
||||
});
|
||||
case ValidationErrors.EXTENSION:
|
||||
return getLangText(
|
||||
// eslint-disable-next-line max-len
|
||||
'Cancelled upload of "${name}" as it has an invalid file format (valid formats: ${allowedExt})', {
|
||||
allowedExt: description.allowedExtensions.join(', '),
|
||||
name: file.name
|
||||
}
|
||||
);
|
||||
default:
|
||||
return getLangText(
|
||||
'Cancelled upload of "${name}" as it failed file validation',
|
||||
file
|
||||
);
|
||||
}
|
||||
})
|
||||
.forEach((errorMsg) => {
|
||||
const notification = new GlobalNotificationModel(errorMsg, 'danger', 5000);
|
||||
GlobalNotificationActions.appendGlobalNotification(notification);
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
invoked,
|
||||
result
|
||||
} = safeInvoke(this.props.onValidationError, errors, validFiles, ...args);
|
||||
|
||||
return invoked ? result : validFiles;
|
||||
},
|
||||
|
||||
render() {
|
||||
const { AscribeUploader, AscribeFileHashUploader } = this;
|
||||
const { hashLocally, ...props } = this.props;
|
||||
|
||||
const uploaderProps = {
|
||||
...props,
|
||||
onCanceled: this.onCanceled,
|
||||
onDeleteComplete: this.onDeleteComplete,
|
||||
onError: this.onError,
|
||||
onValidationError: this.onValidationError,
|
||||
|
||||
// Mandatory for uploaderSpecExtender
|
||||
ref: 'uploader'
|
||||
};
|
||||
|
||||
return hashLocally ? (
|
||||
<AscribeFileHashUploader
|
||||
{...uploaderProps}
|
||||
onFileHashError={this.onFileHashError} />
|
||||
) : (<AscribeUploader {...uploaderProps} />);
|
||||
}
|
||||
}));
|
||||
|
||||
export default Uploader;
|
@ -4,11 +4,11 @@ import React from 'react';
|
||||
const { number, object, shape, string } = React.PropTypes;
|
||||
|
||||
const currentUserShapeSpec = {
|
||||
acl: object,
|
||||
email: string,
|
||||
id: number,
|
||||
profile: object,
|
||||
username: string
|
||||
acl: object.isRequired,
|
||||
email: string.isRequired,
|
||||
id: number.isRequired,
|
||||
profile: object.isRequired,
|
||||
username: string.isRequired
|
||||
};
|
||||
|
||||
export default shape(currentUserShapeSpec);
|
||||
|
@ -4,10 +4,10 @@ import React from 'react';
|
||||
const { shape, string } = React.PropTypes;
|
||||
|
||||
const whitelabelShapeSpec = {
|
||||
name: string,
|
||||
subdomain: string,
|
||||
title: string,
|
||||
user: string
|
||||
name: string.isRequired,
|
||||
subdomain: string.isRequired,
|
||||
title: string.isRequired,
|
||||
user: string.isRequired
|
||||
};
|
||||
|
||||
export default shape(whitelabelShapeSpec);
|
||||
|
@ -1,3 +1,13 @@
|
||||
// Upload types:
|
||||
// * HASH: Hash the file before uploading
|
||||
// * NORMAL: Upload the file as-is
|
||||
// * USE_URL_PARAM: Determine the type to use through the current url's `upload_method` parameter
|
||||
export const UploadMethods = {
|
||||
HASH: 'hash',
|
||||
NORMAL: 'normal',
|
||||
USE_URL_PARAM: 'use_url_param'
|
||||
};
|
||||
|
||||
// Validation types
|
||||
export const ValidationParts = {
|
||||
allowedExtensions: {
|
||||
@ -39,6 +49,7 @@ export const S3_ACL = process.env.S3_ACL;
|
||||
export const S3_BUCKET = process.env.S3_BUCKET;
|
||||
|
||||
export default {
|
||||
UploadMethods,
|
||||
ValidationParts,
|
||||
ValidationTypes,
|
||||
RETRY_ATTEMPT_TO_SHOW_CONTACT_US,
|
||||
|
@ -5,3 +5,14 @@ export {
|
||||
extractFileExtensionFromString,
|
||||
extractFileExtensionFromUrl
|
||||
} from 'js-utility-belt/es6/file';
|
||||
|
||||
/**
|
||||
* Transforms kb size to mb size, using an arbitrary rounding function at the end.
|
||||
*
|
||||
* @param {number} size Size in kb
|
||||
* @param {function} roundFn Function to round out the precise size in mb, defaulting to Math.ceil.
|
||||
* @return {number} Size in mb
|
||||
*/
|
||||
export function kbToMb(size, roundFn = Math.ceil) {
|
||||
return roundFn(size / 1024);
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ export {
|
||||
deepMatchObject,
|
||||
intersectLists,
|
||||
omitFromObject,
|
||||
safeInvoke,
|
||||
safeMerge,
|
||||
sanitize,
|
||||
sanitizeList,
|
||||
|
@ -9,21 +9,18 @@
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
padding-top: 1.5em;
|
||||
|
||||
@media screen and (max-width: 625px) {
|
||||
.file-name {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inactive-dropzone {
|
||||
background-color: rgba(0, 0, 0, 0) !important;
|
||||
background-color: transparent !important;
|
||||
cursor: default !important;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.present-options {
|
||||
.file-drag-and-drop-dialog {
|
||||
margin: 0 0 1.5em 0;
|
||||
|
||||
.present-options {
|
||||
> p {
|
||||
margin-bottom: .75em !important;
|
||||
}
|
||||
@ -31,10 +28,7 @@
|
||||
.btn {
|
||||
margin: 0 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.file-drag-and-drop-dialog {
|
||||
margin: 0 0 1.5em 0;
|
||||
}
|
||||
|
||||
.file-drag-and-drop-dialog-title {
|
||||
font-size: 1.5em !important;
|
||||
@ -46,6 +40,10 @@
|
||||
> .btn {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.file-drag-and-drop-overall-progress-bar {
|
||||
margin-top: 1.3em;
|
||||
}
|
||||
}
|
||||
|
||||
.file-drag-and-drop-hashing-dialog {
|
||||
@ -53,44 +51,67 @@
|
||||
margin: 1.5em 0 0 0;
|
||||
}
|
||||
|
||||
.file-drag-and-drop-position {
|
||||
.remove-file-btn {
|
||||
background-color: black;
|
||||
border-width: 0;
|
||||
border-radius: 1em;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
|
||||
.glyphicon-remove {
|
||||
color: white;
|
||||
font-size: .8em;
|
||||
left: 0;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
&:hover .glyphicon-remove {
|
||||
color: $brand-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-file-preview-container {
|
||||
display: inline-block;
|
||||
margin-left: .7em;
|
||||
margin-right: .7em;
|
||||
position: relative;
|
||||
|
||||
.delete-file {
|
||||
background-color: black;
|
||||
border-radius: 1em;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
right: -7px;
|
||||
text-align: center;
|
||||
top: -7px;
|
||||
width: 20px;
|
||||
|
||||
span {
|
||||
color: white;
|
||||
font-size: .8em;
|
||||
left: 0;
|
||||
top: 1px;
|
||||
|
||||
&:hover {
|
||||
color: $brand-danger;
|
||||
&:not(:only-of-type) {
|
||||
.upload-file-preview {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.upload-file-preview--label {
|
||||
padding-top: 5px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-drag-and-drop-preview {
|
||||
.upload-file-preview {
|
||||
background-color: #eeeeee;
|
||||
border: 1px solid #616161;
|
||||
cursor: default;
|
||||
overflow: hidden;
|
||||
|
||||
.remove-file-btn {
|
||||
position: absolute;
|
||||
right: -7px;
|
||||
top: -7px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-file-preview--label {
|
||||
@media screen and (max-width: 625px) {
|
||||
padding-top: 5px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.action-file {
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
@ -146,7 +167,7 @@
|
||||
text-align: center;
|
||||
width: 104px;
|
||||
|
||||
p {
|
||||
.file-drag-and-drop-preview-other--label {
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@ -319,8 +340,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.upload-button .upload-button--label {
|
||||
margin-left: 1em;
|
||||
|
||||
.btn {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.upload-button--label-icon {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user