Merge with master

This commit is contained in:
Brett Sun 2016-01-29 11:35:43 +01:00
commit 841cc05525
35 changed files with 875 additions and 308 deletions

3
.env-template Normal file
View File

@ -0,0 +1,3 @@
SAUCE_USERNAME=ascribe
SAUCE_ACCESS_KEY=
SAUCE_DEFAULT_URL=

View File

@ -2,7 +2,7 @@
"parser": "babel-eslint",
"env": {
"browser": true,
"es6": true
"es6": true,
},
"rules": {
"new-cap": [2, {newIsCap: true, capIsNew: false}],

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ gemini-report/*
node_modules/*
.DS_Store
.env

View File

@ -57,6 +57,7 @@ We use [ESLint](https://github.com/eslint/eslint) with our own [custom ruleset](
SCSS Code Conventions
=====================
Install [lint-scss](https://github.com/brigade/scss-lint), check the [editor integration docs](https://github.com/brigade/scss-lint#editor-integration) to integrate the lint in your editor.
Some interesting links:
@ -66,15 +67,17 @@ Some interesting links:
Branch names
============
Since we moved to Github, we cannot create branch names automatically with JIRA anymore.
To not lose context, but still be able to switch branches quickly using a ticket's number, we're recommending the following rules when naming our branches in onion.
To allow Github and JIRA to track branches while still allowing us to switch branches quickly using a ticket's number (and keep our peace of mind), we have the following rules for naming branches:
```
// For issues logged in Github:
AG-<Github-issue-id>-brief-and-sane-description-of-the-ticket
// For issues logged in JIRA:
AD-<JIRA-ticket-id>-brief-and-sane-description-of-the-ticket
```
where `brief-and-sane-description-of-the-ticket` does not need to equal to the ticket's title.
This allows JIRA to still track branches and pull-requests while allowing us to keep our peace of mind.
where `brief-and-sane-description-of-the-ticket` does not need to equal to the issue or ticket's title.
Example

View File

@ -39,7 +39,7 @@ class EditionListActions {
return Q.Promise((resolve, reject) => {
EditionListFetcher
.fetch({ pieceId, page, itemsToFetch, orderBy, orderAsc, filterBy })
.fetch({ pieceId, page, orderBy, orderAsc, filterBy, pageSize: itemsToFetch })
.then((res) => {
if (res && !res.editions) {
throw new Error('Piece has no editions to fetch.');

View File

@ -8,6 +8,7 @@ import ReactS3FineUploader from './../ascribe_uploader/react_s3_fine_uploader';
import ApiUrls from '../../constants/api_urls';
import AppConstants from '../../constants/application_constants';
import { validationTypes } from '../../constants/uploader_constants';
import { getCookie } from '../../utils/fetch_api_utils';
import { getLangText } from '../../utils/lang_utils';
@ -15,23 +16,26 @@ import { getLangText } from '../../utils/lang_utils';
let FurtherDetailsFileuploader = React.createClass({
propTypes: {
pieceId: React.PropTypes.number.isRequired,
areAssetsDownloadable: React.PropTypes.bool,
editable: React.PropTypes.bool,
isReadyForFormSubmission: React.PropTypes.func,
label: React.PropTypes.string,
pieceId: React.PropTypes.number,
multiple: React.PropTypes.bool,
otherData: React.PropTypes.arrayOf(React.PropTypes.object),
onValidationFailed: React.PropTypes.func,
setIsUploadReady: React.PropTypes.func,
submitFile: React.PropTypes.func,
onValidationFailed: React.PropTypes.func,
isReadyForFormSubmission: React.PropTypes.func,
editable: React.PropTypes.bool,
multiple: React.PropTypes.bool,
areAssetsDownloadable: React.PropTypes.bool
validation: ReactS3FineUploader.propTypes.validation
},
getDefaultProps() {
return {
areAssetsDownloadable: true,
label: getLangText('Additional files'),
multiple: false
multiple: false,
validation: validationTypes.additionalData
};
},
@ -61,7 +65,7 @@ let FurtherDetailsFileuploader = React.createClass({
url: ApiUrls.blob_otherdatas,
pieceId: this.props.pieceId
}}
validation={AppConstants.fineUploader.validation.additionalData}
validation={this.props.validation}
submitFile={this.props.submitFile}
onValidationFailed={this.props.onValidationFailed}
setIsUploadReady={this.props.setIsUploadReady}

View File

@ -65,7 +65,16 @@ let MediaContainer = React.createClass({
// We also force uniqueness of usernames, so this check is safe to dtermine if the
// content was registered by the current user.
const didUserRegisterContent = currentUser && (currentUser.username === content.user_registered);
const fileExtension = extractFileExtensionFromString(content.digital_work.url);
// We want to show the file's extension as a label of the download button.
// We can however not only use `extractFileExtensionFromString` on the url for that
// as files might be saved on S3 without a file extension which leads
// `extractFileExtensionFromString` to extract everything starting from the top level
// domain: e.g. '.net/live/<hash>'.
// Therefore, we extract the file's name (last part of url, separated with a slash)
// and try to extract the file extension from there.
const fileName = content.digital_work.url.split('/').pop();
const fileExtension = extractFileExtensionFromString(fileName);
let thumbnail = content.thumbnail.thumbnail_sizes && content.thumbnail.thumbnail_sizes['600x600'] ?
content.thumbnail.thumbnail_sizes['600x600'] : content.thumbnail.url_safe;
@ -123,7 +132,11 @@ let MediaContainer = React.createClass({
className="ascribe-margin-1px"
href={content.digital_work.url}
target="_blank">
{getLangText('Download')} .{fileExtension} <Glyphicon glyph="cloud-download"/>
{/*
If it turns out that `fileExtension` is an empty string, we're just
using the label 'file'.
*/}
{getLangText('Download')} .{fileExtension || 'file'} <Glyphicon glyph="cloud-download"/>
</Button>
</AclProxy>
{embed}

View File

@ -2,19 +2,20 @@
import React from 'react';
import Form from '../ascribe_forms/form';
import Property from '../ascribe_forms/property';
import ContractListActions from '../../actions/contract_list_actions';
import GlobalNotificationModel from '../../models/global_notification_model';
import GlobalNotificationActions from '../../actions/global_notification_actions';
import ContractListActions from '../../actions/contract_list_actions';
import AppConstants from '../../constants/application_constants';
import ApiUrls from '../../constants/api_urls';
import InputFineUploader from './input_fineuploader';
import Form from '../ascribe_forms/form';
import Property from '../ascribe_forms/property';
import ApiUrls from '../../constants/api_urls';
import AppConstants from '../../constants/application_constants';
import { validationTypes } from '../../constants/uploader_constants';
import { getLangText } from '../../utils/lang_utils';
import { formSubmissionValidation } from '../ascribe_uploader/react_s3_fine_uploader_utils';
@ -78,8 +79,8 @@ let CreateContractForm = React.createClass({
url: ApiUrls.blob_contracts
}}
validation={{
itemLimit: AppConstants.fineUploader.validation.additionalData.itemLimit,
sizeLimit: AppConstants.fineUploader.validation.additionalData.sizeLimit,
itemLimit: validationTypes.additionalData.itemLimit,
sizeLimit: validationTypes.additionalData.sizeLimit,
allowedExtensions: ['pdf']
}}
areAssetsDownloadable={true}

View File

@ -8,12 +8,16 @@ import UserActions from '../../actions/user_actions';
import Form from './form';
import Property from './property';
import InputFineUploader from './input_fineuploader';
import UploadButton from '../ascribe_uploader/ascribe_upload_button/upload_button';
import FormSubmitButton from '../ascribe_buttons/form_submit_button';
import UploadButton from '../ascribe_uploader/ascribe_upload_button/upload_button';
import AscribeSpinner from '../ascribe_spinner';
import ApiUrls from '../../constants/api_urls';
import AppConstants from '../../constants/application_constants';
import AscribeSpinner from '../ascribe_spinner';
import { validationParts, validationTypes } from '../../constants/uploader_constants';
import { getLangText } from '../../utils/lang_utils';
import { mergeOptions } from '../../utils/general_utils';
@ -180,7 +184,7 @@ let RegisterPieceForm = React.createClass({
createBlobRoutine={{
url: ApiUrls.blob_digitalworks
}}
validation={AppConstants.fineUploader.validation.registerWork}
validation={validationTypes.registerWork}
setIsUploadReady={this.setIsUploadReady('digitalWorkKeyReady')}
isReadyForFormSubmission={formSubmissionValidation.atLeastOneUploadedFile}
isFineUploaderActive={isFineUploaderActive}
@ -206,9 +210,9 @@ let RegisterPieceForm = React.createClass({
fileClass: 'thumbnail'
}}
validation={{
itemLimit: AppConstants.fineUploader.validation.workThumbnail.itemLimit,
sizeLimit: AppConstants.fineUploader.validation.workThumbnail.sizeLimit,
allowedExtensions: ['png', 'jpg', 'jpeg', 'gif']
itemLimit: validationTypes.workThumbnail.itemLimit,
sizeLimit: validationTypes.workThumbnail.sizeLimit,
allowedExtensions: validationParts.allowedExtensions.images
}}
setIsUploadReady={this.setIsUploadReady('thumbnailKeyReady')}
fileClassToUpload={{

View File

@ -28,11 +28,7 @@ const InputFineUploader = React.createClass({
createBlobRoutine: shape({
url: string
}),
validation: shape({
itemLimit: number,
sizeLimit: string,
allowedExtensions: arrayOf(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

View File

@ -2,17 +2,18 @@
import React from 'react';
import ReactS3FineUploader from '../ascribe_uploader/react_s3_fine_uploader';
import UploadButton from '../ascribe_uploader/ascribe_upload_button/upload_button';
import AppConstants from '../../constants/application_constants';
import ApiUrls from '../../constants/api_urls';
import ContractListActions from '../../actions/contract_list_actions';
import GlobalNotificationModel from '../../models/global_notification_model';
import GlobalNotificationActions from '../../actions/global_notification_actions';
import ReactS3FineUploader from '../ascribe_uploader/react_s3_fine_uploader';
import UploadButton from '../ascribe_uploader/ascribe_upload_button/upload_button';
import ApiUrls from '../../constants/api_urls';
import AppConstants from '../../constants/application_constants';
import { validationTypes } from '../../constants/uploader_constants';
import { formSubmissionValidation } from '../ascribe_uploader/react_s3_fine_uploader_utils';
import { getCookie } from '../../utils/fetch_api_utils';
import { getLangText } from '../../utils/lang_utils';
@ -68,8 +69,8 @@ let ContractSettingsUpdateButton = React.createClass({
url: ApiUrls.blob_contracts
}}
validation={{
itemLimit: AppConstants.fineUploader.validation.registerWork.itemLimit,
sizeLimit: AppConstants.fineUploader.validation.additionalData.sizeLimit,
itemLimit: validationTypes.registerWork.itemLimit,
sizeLimit: validationTypes.additionalData.sizeLimit,
allowedExtensions: ['pdf']
}}
setIsUploadReady={() =>{/* So that ReactS3FineUploader is not complaining */}}

View File

@ -23,6 +23,7 @@ const FileDragAndDropPreview = React.createClass({
s3Url: string,
s3UrlSafe: string
}).isRequired,
handleDeleteFile: func,
handleCancelFile: func,
handlePauseFile: func,
@ -33,9 +34,9 @@ const FileDragAndDropPreview = React.createClass({
},
toggleUploadProcess() {
if(this.props.file.status === 'uploading') {
if (this.props.file.status === 'uploading') {
this.props.handlePauseFile(this.props.file.id);
} else if(this.props.file.status === 'paused') {
} else if (this.props.file.status === 'paused') {
this.props.handleResumeFile(this.props.file.id);
}
},
@ -54,13 +55,13 @@ const FileDragAndDropPreview = React.createClass({
(file.status === 'upload successful' || file.status === 'online') &&
file.s3UrlSafe) {
handleDeleteFile(file.id);
} else if(handleCancelFile) {
} else if (handleCancelFile) {
handleCancelFile(file.id);
}
},
handleDownloadFile() {
if(this.props.file.s3Url) {
if (this.props.file.s3Url) {
// This simply opens a new browser tab with the url provided
open(this.props.file.s3Url);
}
@ -69,7 +70,7 @@ const FileDragAndDropPreview = React.createClass({
getFileName() {
const { numberOfDisplayedFiles, file } = this.props;
if(numberOfDisplayedFiles === 1) {
if (numberOfDisplayedFiles === 1) {
return (
<span className="file-name">
{truncateTextAtCharIndex(file.name, 30, '(...).' + extractFileExtensionFromString(file.name))}
@ -81,7 +82,7 @@ const FileDragAndDropPreview = React.createClass({
},
getRemoveButton() {
if(this.props.areAssetsEditable) {
if (this.props.areAssetsEditable) {
return (
<div className="delete-file">
<span
@ -107,7 +108,7 @@ const FileDragAndDropPreview = React.createClass({
// Decide whether an image or a placeholder picture should be displayed
// If a file has its `thumbnailUrl` defined, then we display it also as an image
if(file.type.split('/')[0] === 'image' || file.thumbnailUrl) {
if (file.type.split('/')[0] === 'image' || file.thumbnailUrl) {
previewElement = (
<FileDragAndDropPreviewImage
onClick={this.handleDeleteFile}
@ -123,7 +124,7 @@ const FileDragAndDropPreview = React.createClass({
<FileDragAndDropPreviewOther
onClick={this.handleDeleteFile}
progress={file.progress}
type={file.type.split('/')[1]}
type={extractFileExtensionFromString(file.name)}
toggleUploadProcess={this.toggleUploadProcess}
areAssetsDownloadable={areAssetsDownloadable}
downloadUrl={file.s3UrlSafe}

View File

@ -56,9 +56,9 @@ const FileDragAndDropPreviewOther = React.createClass({
target="_blank"
className="glyphicon glyphicon-download action-file"
aria-hidden="true"
title={getLangText('Download file')}/>
title={getLangText('Download file')} />
);
} else if(progress >= 0 && progress < 100) {
} else if (progress >= 0 && progress < 100) {
actionSymbol = (
<div className="spinner-file">
<AscribeSpinner color='dark-blue' size='md' />
@ -66,22 +66,19 @@ const FileDragAndDropPreviewOther = React.createClass({
);
} else {
actionSymbol = (
<span className='ascribe-icon icon-ascribe-ok action-file'/>
<span className='ascribe-icon icon-ascribe-ok action-file' />
);
}
return (
<div
className="file-drag-and-drop-preview">
<div className="file-drag-and-drop-preview">
<ProgressBar
now={Math.ceil(progress)}
style={style}
className="ascribe-progress-bar ascribe-progress-bar-xs"/>
<div className="file-drag-and-drop-preview-table-wrapper">
<div className="file-drag-and-drop-preview-other">
{actionSymbol}
<p style={style}>{'.' + type}</p>
</div>
className="ascribe-progress-bar ascribe-progress-bar-xs" />
<div className="file-drag-and-drop-preview-other">
{actionSymbol}
<p style={style}>{'.' + (type ? type : 'file')}</p>
</div>
</div>
);

View File

@ -13,9 +13,9 @@ import GlobalNotificationActions from '../../actions/global_notification_actions
import AppConstants from '../../constants/application_constants';
import { computeHashOfFile } from '../../utils/file_utils';
import { displayValidFilesFilter, 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';
@ -91,7 +91,7 @@ const ReactS3FineUploader = React.createClass({
}),
validation: shape({
itemLimit: number,
sizeLimit: string,
sizeLimit: number,
allowedExtensions: arrayOf(string)
}),
messages: shape({
@ -278,22 +278,6 @@ const ReactS3FineUploader = React.createClass({
this.setState(this.getInitialState());
},
// Cancel uploads and clear previously selected files on the input element
cancelUploads(id) {
typeof id !== 'undefined' ? this.state.uploader.cancel(id) : this.state.uploader.cancelAll();
// Reset the file input element to clear the previously selected files so that
// the user can reselect them again.
this.clearFileSelection();
},
clearFileSelection() {
const { fileInput } = this.refs;
if (fileInput && typeof fileInput.clearSelection === 'function') {
fileInput.clearSelection();
}
},
requestKey(fileId) {
let filename = this.state.uploader.getName(fileId);
let uuid = this.state.uploader.getUuid(fileId);
@ -384,6 +368,107 @@ const ReactS3FineUploader = React.createClass({
});
},
// Cancel uploads and clear previously selected files on the input element
cancelUploads(id) {
typeof id !== 'undefined' ? this.state.uploader.cancel(id) : this.state.uploader.cancelAll();
// Reset the file input element to clear the previously selected files so that
// the user can reselect them again.
this.clearFileSelection();
},
clearFileSelection() {
const { fileInput } = this.refs;
if (fileInput && typeof fileInput.clearSelection === 'function') {
fileInput.clearSelection();
}
},
getAllowedExtensions() {
const { validation: { allowedExtensions } = {} } = this.props;
if (allowedExtensions && allowedExtensions.length) {
return transformAllowedExtensionsToInputAcceptProp(allowedExtensions);
} else {
return null;
}
},
getXhrErrorComment(xhr) {
if (xhr) {
return {
response: xhr.response,
url: xhr.responseURL,
status: xhr.status,
statusText: xhr.statusText
};
}
},
isDropzoneInactive() {
const filesToDisplay = this.state.filesToUpload.filter((file) => file.status !== 'deleted' && file.status !== 'canceled' && file.size !== -1);
if ((this.props.enableLocalHashing && !this.props.uploadMethod) || !this.props.areAssetsEditable || !this.props.multiple && filesToDisplay.length > 0) {
return true;
} else {
return false;
}
},
isFileValid(file) {
const { validation: { allowedExtensions, sizeLimit = 0 }, onValidationFailed } = this.props;
const fileExt = extractFileExtensionFromString(file.name);
if (file.size > sizeLimit) {
const fileSizeInMegaBytes = sizeLimit / 1000000;
const notification = new GlobalNotificationModel(getLangText('A file you submitted is bigger than ' + fileSizeInMegaBytes + 'MB.'), 'danger', 5000);
GlobalNotificationActions.appendGlobalNotification(notification);
if (typeof onValidationFailed === 'function') {
onValidationFailed(file);
}
return false;
} else if (allowedExtensions && !allowedExtensions.includes(fileExt)) {
const notification = new GlobalNotificationModel(getLangText(`The file you've submitted is of an invalid file format: Valid format(s): ${allowedExtensions.join(', ')}`), 'danger', 5000);
GlobalNotificationActions.appendGlobalNotification(notification);
return false;
} else {
return true;
}
},
selectValidFiles(files) {
return Array.from(files).reduce((validFiles, file) => {
if (this.isFileValid(file)) {
validFiles.push(file);
}
return validFiles;
}, []);
},
// This method has been made promise-based to immediately afterwards
// call a callback function (instantly after this.setState went through)
// This is e.g. needed when showing/hiding the optional thumbnail upload
// field in the registration form
setStatusOfFile(fileId, status) {
return Q.Promise((resolve) => {
let changeSet = {};
if(status === 'deleted' || status === 'canceled') {
changeSet.progress = { $set: 0 };
}
changeSet.status = { $set: status };
let filesToUpload = React.addons.update(this.state.filesToUpload, { [fileId]: changeSet });
this.setState({ filesToUpload }, resolve);
});
},
setThumbnailForFileId(fileId, url) {
const { filesToUpload } = this.state;
@ -506,34 +591,6 @@ const ReactS3FineUploader = React.createClass({
GlobalNotificationActions.appendGlobalNotification(notification);
},
getXhrErrorComment(xhr) {
if (xhr) {
return {
response: xhr.response,
url: xhr.responseURL,
status: xhr.status,
statusText: xhr.statusText
};
}
},
isFileValid(file) {
if (file.size > this.props.validation.sizeLimit) {
const fileSizeInMegaBytes = this.props.validation.sizeLimit / 1000000;
const notification = new GlobalNotificationModel(getLangText('A file you submitted is bigger than ' + fileSizeInMegaBytes + 'MB.'), 'danger', 5000);
GlobalNotificationActions.appendGlobalNotification(notification);
if (typeof this.props.onValidationFailed === 'function') {
this.props.onValidationFailed(file);
}
return false;
} else {
return true;
}
},
onCancel(id) {
// when a upload is canceled, we need to update this components file array
this.setStatusOfFile(id, 'canceled')
@ -670,6 +727,13 @@ const ReactS3FineUploader = React.createClass({
this.cancelUploads(fileId);
},
handleCancelHashing() {
// Every progress tick of the hashing function in handleUploadFile there is a
// check if this.state.hashingProgress is -1. If so, there is an error thrown that cancels
// the hashing of all files immediately.
this.setState({ hashingProgress: -1 });
},
handlePauseFile(fileId) {
if(this.state.uploader.pauseUpload(fileId)) {
this.setStatusOfFile(fileId, 'paused');
@ -698,15 +762,8 @@ const ReactS3FineUploader = React.createClass({
return;
}
// validate each submitted file if it fits the file size
let validFiles = [];
for(let i = 0; i < files.length; i++) {
if(this.isFileValid(files[i])) {
validFiles.push(files[i]);
}
}
// override standard files list with only valid files
files = validFiles;
// Select only the submitted files that fit the file size and allowed extensions
files = this.selectValidFiles(files);
// if multiple is set to false and user drops multiple files into the dropzone,
// take the first one and notify user that only one file can be submitted
@ -817,13 +874,6 @@ const ReactS3FineUploader = React.createClass({
}
},
handleCancelHashing() {
// Every progress tick of the hashing function in handleUploadFile there is a
// check if this.state.hashingProgress is -1. If so, there is an error thrown that cancels
// the hashing of all files immediately.
this.setState({ hashingProgress: -1 });
},
// ReactFineUploader is essentially just a react layer around s3 fineuploader.
// However, since we need to display the status of a file (progress, uploading) as well as
// be able to execute actions on a currently uploading file we need to exactly sync the file list
@ -893,46 +943,6 @@ const ReactS3FineUploader = React.createClass({
});
},
// This method has been made promise-based to immediately afterwards
// call a callback function (instantly after this.setState went through)
// This is e.g. needed when showing/hiding the optional thumbnail upload
// field in the registration form
setStatusOfFile(fileId, status) {
return Q.Promise((resolve) => {
let changeSet = {};
if(status === 'deleted' || status === 'canceled') {
changeSet.progress = { $set: 0 };
}
changeSet.status = { $set: status };
let filesToUpload = React.addons.update(this.state.filesToUpload, { [fileId]: changeSet });
this.setState({ filesToUpload }, resolve);
});
},
isDropzoneInactive() {
const filesToDisplay = this.state.filesToUpload.filter((file) => file.status !== 'deleted' && file.status !== 'canceled' && file.size !== -1);
if ((this.props.enableLocalHashing && !this.props.uploadMethod) || !this.props.areAssetsEditable || !this.props.multiple && filesToDisplay.length > 0) {
return true;
} else {
return false;
}
},
getAllowedExtensions() {
let { validation } = this.props;
if(validation && validation.allowedExtensions && validation.allowedExtensions.length > 0) {
return transformAllowedExtensionsToInputAcceptProp(validation.allowedExtensions);
} else {
return null;
}
},
render() {
const {
multiple,

View File

@ -18,6 +18,8 @@ import AclProxy from './acl_proxy';
import EventActions from '../actions/event_actions';
import PieceListStore from '../stores/piece_list_store';
import UserActions from '../actions/user_actions';
import UserStore from '../stores/user_store';
@ -43,12 +45,17 @@ let Header = React.createClass({
getInitialState() {
return mergeOptions(
PieceListStore.getState(),
WhitelabelStore.getState(),
UserStore.getState()
);
},
componentDidMount() {
// Listen to the piece list store, but don't fetch immediately to avoid
// conflicts with routes that may need to wait to load the piece list
PieceListStore.listen(this.onChange);
UserStore.listen(this.onChange);
UserActions.fetchCurrentUser.defer();
@ -75,11 +82,16 @@ let Header = React.createClass({
},
componentWillUnmount() {
PieceListStore.unlisten(this.onChange);
UserStore.unlisten(this.onChange);
WhitelabelStore.unlisten(this.onChange);
//history.unlisten(this.onRouteChange);
},
onChange(state) {
this.setState(state);
},
getLogo() {
let { whitelabel } = this.state;
@ -93,16 +105,16 @@ let Header = React.createClass({
<img className="img-brand" src={whitelabel.logo} alt="Whitelabel brand"/>
</Link>
);
} else {
return (
<span>
<Link className="icon-ascribe-logo" to="/collection"/>
</span>
);
}
return (
<span>
<Link className="icon-ascribe-logo" to="/collection"/>
</span>
);
},
getPoweredBy(){
getPoweredBy() {
return (
<AclProxy
aclObject={this.state.whitelabel}
@ -117,10 +129,6 @@ let Header = React.createClass({
);
},
onChange(state) {
this.setState(state);
},
onMenuItemClick() {
/*
This is a hack to make the dropdown close after clicking on an item
@ -156,16 +164,18 @@ let Header = React.createClass({
},
render() {
const { currentUser, unfilteredPieceListCount } = this.state;
let account;
let signup;
let navRoutesLinks;
if (this.state.currentUser.username){
if (currentUser.username) {
account = (
<DropdownButton
ref='dropdownbutton'
id="nav-route-user-dropdown"
eventKey="1"
title={this.state.currentUser.username}>
title={currentUser.username}>
<LinkContainer
to="/settings"
onClick={this.onMenuItemClick}>
@ -175,7 +185,7 @@ let Header = React.createClass({
</MenuItem>
</LinkContainer>
<AclProxy
aclObject={this.state.currentUser.acl}
aclObject={currentUser.acl}
aclName="acl_view_settings_contract">
<LinkContainer
to="/contract_settings"
@ -196,9 +206,21 @@ let Header = React.createClass({
</LinkContainer>
</DropdownButton>
);
navRoutesLinks = <NavRoutesLinks routes={this.props.routes} userAcl={this.state.currentUser.acl} navbar right/>;
}
else {
// Let's assume that if the piece list hasn't loaded yet (ie. when unfilteredPieceListCount === -1)
// then the user has pieces
// FIXME: this doesn't work that well as the user may not load their piece list
// until much later, so we would show the 'Collection' header as available until
// they actually click on it and get redirected to piece registration.
navRoutesLinks = (
<NavRoutesLinks
navbar
right
hasPieces={!!unfilteredPieceListCount}
routes={this.props.routes}
userAcl={currentUser.acl} />
);
} else {
account = (
<LinkContainer
to="/login">

View File

@ -11,14 +11,33 @@ import AclProxy from './acl_proxy';
import { sanitizeList } from '../utils/general_utils';
const DISABLE_ENUM = ['hasPieces', 'noPieces'];
let NavRoutesLinks = React.createClass({
propTypes: {
hasPieces: React.PropTypes.bool,
routes: React.PropTypes.arrayOf(React.PropTypes.object),
userAcl: React.PropTypes.object
},
isRouteDisabled(disableOn) {
const { hasPieces } = this.props;
if (disableOn) {
if (!DISABLE_ENUM.includes(disableOn)) {
throw new Error(`"disableOn" must be one of: [${DISABLE_ENUM.join(', ')}] got "${disableOn}" instead`);
}
if (disableOn === 'hasPieces') {
return hasPieces;
} else if (disableOn === 'noPieces') {
return !hasPieces;
}
}
},
/**
* This method generales a bunch of react-bootstrap specific links
* This method generates a bunch of react-bootstrap specific links
* from the routes we defined in one of the specific routes.js file
*
* We can define a headerTitle as well as a aclName and according to that the
@ -29,48 +48,50 @@ let NavRoutesLinks = React.createClass({
* @return {Array} Array of ReactElements that can be displayed to the user
*/
extractLinksFromRoutes(node, userAcl, i) {
if(!node) {
if (!node) {
return;
}
let links = node.childRoutes.map((child, j) => {
let childrenFn = null;
let { aclName, headerTitle, path, childRoutes } = child;
// If the node has children that could be rendered, then we want
// to execute this function again with the child as the root
//
// Otherwise we'll just pass childrenFn as false
if(child.childRoutes && child.childRoutes.length > 0) {
childrenFn = this.extractLinksFromRoutes(child, userAcl, i++);
}
const links = node.childRoutes.map((child, j) => {
const { aclName, disableOn, headerTitle, path, childRoutes } = child;
// We validate if the user has set the title correctly,
// otherwise we're not going to render his route
if(headerTitle && typeof headerTitle === 'string') {
if (headerTitle && typeof headerTitle === 'string') {
let nestedChildren = null;
// If the node has children that could be rendered, then we want
// to execute this function again with the child as the root
//
// Otherwise we'll just pass nestedChildren as false
if (child.childRoutes && child.childRoutes.length) {
nestedChildren = this.extractLinksFromRoutes(child, userAcl, i++);
}
const navLinkProps = {
headerTitle,
children: nestedChildren,
depth: i,
disabled: this.isRouteDisabled(disableOn),
routePath: `/${path}`
};
// if there is an aclName present on the route definition,
// we evaluate it against the user's acl
if(aclName && typeof aclName !== 'undefined') {
if (aclName && typeof aclName !== 'undefined') {
return (
<AclProxy
key={j}
aclName={aclName}
aclObject={this.props.userAcl}>
<NavRoutesLinksLink
headerTitle={headerTitle}
routePath={'/' + path}
depth={i}
children={childrenFn}/>
<NavRoutesLinksLink {...navLinkProps} />
</AclProxy>
);
} else {
return (
<NavRoutesLinksLink
key={j}
headerTitle={headerTitle}
routePath={'/' + path}
depth={i}
children={childrenFn}/>
{...navLinkProps} />
);
}
} else {
@ -84,7 +105,7 @@ let NavRoutesLinks = React.createClass({
},
render() {
let {routes, userAcl} = this.props;
const {routes, userAcl} = this.props;
return (
<Nav {...this.props}>
@ -94,4 +115,4 @@ let NavRoutesLinks = React.createClass({
}
});
export default NavRoutesLinks;
export default NavRoutesLinks;

View File

@ -11,42 +11,46 @@ import LinkContainer from 'react-router-bootstrap/lib/LinkContainer';
let NavRoutesLinksLink = React.createClass({
propTypes: {
headerTitle: React.PropTypes.string,
routePath: React.PropTypes.string,
children: React.PropTypes.oneOfType([
React.PropTypes.arrayOf(React.PropTypes.element),
React.PropTypes.element
]),
depth: React.PropTypes.number
disabled: React.PropTypes.bool,
depth: React.PropTypes.number,
headerTitle: React.PropTypes.string,
routePath: React.PropTypes.string
},
render() {
let { children, headerTitle, depth, routePath } = this.props;
const { children, headerTitle, depth, disabled, routePath } = this.props;
// if the route has children, we're returning a DropdownButton that will get filled
// with MenuItems
if(children) {
if (children) {
return (
<DropdownButton
disabled={disabled}
id={`nav-route-${headerTitle.toLowerCase()}-dropdown`}
title={headerTitle}>
{children}
</DropdownButton>
);
} else {
if(depth === 1) {
if (depth === 1) {
// if the node's child is actually a node of level one (a child of a node), we're
// returning a DropdownButton matching MenuItem
return (
<LinkContainer to={routePath}>
<LinkContainer
disabled={disabled}
to={routePath}>
<MenuItem>{headerTitle}</MenuItem>
</LinkContainer>
);
} else if(depth === 0) {
} else if (depth === 0) {
return (
<LinkContainer to={routePath}>
<LinkContainer
disabled={disabled}
to={routePath}>
<NavItem>{headerTitle}</NavItem>
</LinkContainer>
);

View File

@ -53,7 +53,6 @@ let PieceList = React.createClass({
accordionListItemType: AccordionListItemWallet,
bulkModalButtonListType: AclButtonList,
canLoadPieceList: true,
orderParams: ['artist_name', 'title'],
filterParams: [{
label: getLangText('Show works I can'),
items: [
@ -61,7 +60,10 @@ let PieceList = React.createClass({
'acl_consign',
'acl_create_editions'
]
}]
}],
orderParams: ['artist_name', 'title'],
redirectTo: '/register_piece',
shouldRedirect: () => true
};
},

View File

@ -3,19 +3,21 @@
import React from 'react';
import { History } from 'react-router';
import GlobalNotificationModel from '../../../../../../models/global_notification_model';
import GlobalNotificationActions from '../../../../../../actions/global_notification_actions';
import Form from '../../../../../ascribe_forms/form';
import Property from '../../../../../ascribe_forms/property';
import InputTextAreaToggable from '../../../../../ascribe_forms/input_textarea_toggable';
import UploadButton from '../../../../../ascribe_uploader/ascribe_upload_button/upload_button';
import InputFineuploader from '../../../../../ascribe_forms/input_fineuploader';
import UploadButton from '../../../../../ascribe_uploader/ascribe_upload_button/upload_button';
import AscribeSpinner from '../../../../../ascribe_spinner';
import GlobalNotificationModel from '../../../../../../models/global_notification_model';
import GlobalNotificationActions from '../../../../../../actions/global_notification_actions';
import AppConstants from '../../../../../../constants/application_constants';
import ApiUrls from '../../../../../../constants/api_urls';
import AppConstants from '../../../../../../constants/application_constants';
import { validationParts, validationTypes } from '../../../../../../constants/uploader_constants';
import requests from '../../../../../../utils/requests';
@ -193,7 +195,7 @@ const PRRegisterPieceForm = React.createClass({
render() {
const { location } = this.props;
const maxThumbnailSize = AppConstants.fineUploader.validation.workThumbnail.sizeLimit / 1000000;
const maxThumbnailSize = validationTypes.workThumbnail.sizeLimit / 1000000;
return (
<div className="register-piece--form">
@ -305,8 +307,8 @@ const PRRegisterPieceForm = React.createClass({
fileClass: 'digitalwork'
}}
validation={{
itemLimit: AppConstants.fineUploader.validation.registerWork.itemLimit,
sizeLimit: AppConstants.fineUploader.validation.additionalData.sizeLimit,
itemLimit: validationTypes.registerWork.itemLimit,
sizeLimit: validationTypes.additionalData.sizeLimit,
allowedExtensions: ['pdf']
}}
location={location}
@ -318,7 +320,7 @@ const PRRegisterPieceForm = React.createClass({
</Property>
<Property
name="thumbnailKey"
label={`${getLangText('Featured Cover photo')} max ${maxThumbnailSize}MB`}>
label={`${getLangText('Featured Cover photo')} (max ${maxThumbnailSize}MB)`}>
<InputFineuploader
fileInputElement={UploadButton()}
createBlobRoutine={{
@ -331,9 +333,9 @@ const PRRegisterPieceForm = React.createClass({
fileClass: 'thumbnail'
}}
validation={{
itemLimit: AppConstants.fineUploader.validation.workThumbnail.itemLimit,
sizeLimit: AppConstants.fineUploader.validation.workThumbnail.sizeLimit,
allowedExtensions: ['png', 'jpg', 'jpeg', 'gif']
itemLimit: validationTypes.workThumbnail.itemLimit,
sizeLimit: validationTypes.workThumbnail.sizeLimit,
allowedExtensions: validationParts.allowedExtensions.images
}}
location={location}
fileClassToUpload={{
@ -356,8 +358,8 @@ const PRRegisterPieceForm = React.createClass({
fileClass: 'otherdata'
}}
validation={{
itemLimit: AppConstants.fineUploader.validation.registerWork.itemLimit,
sizeLimit: AppConstants.fineUploader.validation.additionalData.sizeLimit
itemLimit: validationParts.itemLimit.single,
sizeLimit: validationTypes.additionalData.sizeLimit
}}
location={location}
fileClassToUpload={{
@ -378,9 +380,9 @@ const PRRegisterPieceForm = React.createClass({
fileClass: 'otherdata'
}}
validation={{
itemLimit: AppConstants.fineUploader.validation.registerWork.itemLimit,
sizeLimit: AppConstants.fineUploader.validation.additionalData.sizeLimit,
allowedExtensions: ['png', 'jpg', 'jpeg', 'gif']
itemLimit: validationParts.itemLimit.single,
sizeLimit: validationTypes.additionalData.sizeLimit,
allowedExtensions: validationParts.allowedExtensions.images
}}
location={location}
fileClassToUpload={{

View File

@ -75,7 +75,6 @@ let PrizePieceList = React.createClass({
<div>
<PieceList
ref="list"
redirectTo="/register_piece"
accordionListItemType={AccordionListItemPrize}
orderParams={orderParams}
orderBy={this.state.currentUser.is_jury ? 'rating' : null}

View File

@ -11,14 +11,14 @@ import InputTextAreaToggable from '../../../../../ascribe_forms/input_textarea_t
import Form from '../../../../../ascribe_forms/form';
import Property from '../../../../../ascribe_forms/property';
import { formSubmissionValidation } from '../../../../../ascribe_uploader/react_s3_fine_uploader_utils';
import AscribeSpinner from '../../../../../ascribe_spinner';
import ApiUrls from '../../../../../../constants/api_urls';
import AppConstants from '../../../../../../constants/application_constants';
import { validationParts, validationTypes } from '../../../../../../constants/uploader_constants';
import requests from '../../../../../../utils/requests';
import { formSubmissionValidation } from '../../../../../ascribe_uploader/react_s3_fine_uploader_utils';
import { mergeOptions } from '../../../../../../utils/general_utils';
import { getLangText } from '../../../../../../utils/lang_utils';
@ -170,7 +170,12 @@ let MarketAdditionalDataForm = React.createClass({
otherData={otherData}
pieceId={pieceId}
setIsUploadReady={this.setIsUploadReady}
submitFile={function () {}} />
submitFile={function () {}}
validation={{
itemLimit: validationTypes.workThumbnail.itemLimit,
sizeLimit: validationTypes.workThumbnail.sizeLimit,
allowedExtensions: validationParts.allowedExtensions.images
}} />
<Property
name='artist_bio'
label={getLangText('Artist Bio')}

View File

@ -75,7 +75,8 @@ let ROUTES = {
<Route
path='collection'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(CylandPieceList)}
headerTitle='COLLECTION' />
headerTitle='COLLECTION'
disableOn='noPieces' />
<Route path='editions/:editionId' component={EditionContainer} />
<Route path='coa_verify' component={CoaVerifyContainer} />
<Route path='pieces/:pieceId' component={CylandPieceContainer} />
@ -109,7 +110,8 @@ let ROUTES = {
<Route
path='collection'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(PieceList)}
headerTitle='COLLECTION' />
headerTitle='COLLECTION'
disableOn='noPieces' />
<Route path='pieces/:pieceId' component={PieceContainer} />
<Route path='editions/:editionId' component={EditionContainer} />
<Route path='coa_verify' component={CoaVerifyContainer} />
@ -150,7 +152,8 @@ let ROUTES = {
<Route
path='collection'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(IkonotvPieceList)}
headerTitle='COLLECTION' />
headerTitle='COLLECTION'
disableOn='noPieces' />
<Route
path='contract_notifications'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(IkonotvContractNotifications)} />
@ -189,7 +192,8 @@ let ROUTES = {
<Route
path='collection'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(MarketPieceList)}
headerTitle='COLLECTION' />
headerTitle='COLLECTION'
disableOn='noPieces' />
<Route path='pieces/:pieceId' component={MarketPieceContainer} />
<Route path='editions/:editionId' component={MarketEditionContainer} />
<Route path='coa_verify' component={CoaVerifyContainer} />
@ -225,7 +229,8 @@ let ROUTES = {
<Route
path='collection'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(Vivi23PieceList)}
headerTitle='COLLECTION' />
headerTitle='COLLECTION'
disableOn='noPieces' />
<Route path='pieces/:pieceId' component={MarketPieceContainer} />
<Route path='editions/:editionId' component={MarketEditionContainer} />
<Route path='coa_verify' component={CoaVerifyContainer} />

View File

@ -68,23 +68,6 @@ const constants = {
// Source: http://www.w3schools.com/tags/att_input_type.asp
'possibleInputTypes': ['button', 'checkbox', 'color', 'date', 'datetime', 'datetime-local', 'email', 'file', 'hidden', 'image', 'month', 'number', 'password', 'radio', 'range', 'reset', 'search', 'submit', 'tel', 'text', 'time', 'url', 'week'],
'fineUploader': {
'validation': {
'additionalData': {
'itemLimit': 100,
'sizeLimit': '25000000000'
},
'registerWork': {
'itemLimit': 1,
'sizeLimit': '25000000000'
},
'workThumbnail': {
'itemLimit': 1,
'sizeLimit': '5000000'
}
}
},
'copyrightAssociations': ['ARS', 'DACS', 'Bildkunst', 'Pictoright', 'SODRAC', 'Copyright Agency/Viscopy', 'SAVA',
'Bildrecht GmbH', 'SABAM', 'AUTVIS', 'CREAIMAGEN', 'SONECA', 'Copydan', 'EAU', 'Kuvasto', 'GCA', 'HUNGART',
'IVARO', 'SIAE', 'JASPAR-SPDA', 'AKKA/LAA', 'LATGA-A', 'SOMAAP', 'ARTEGESTION', 'CARIER', 'BONO', 'APSAV',

View File

@ -0,0 +1,32 @@
'use strict';
export const validationParts = {
allowedExtensions: {
images: ['png', 'jpg', 'jpeg', 'gif']
},
itemLimit: {
single: 1,
multiple: 100
},
sizeLimit: {
default: 50000000000,
thumbnail: 5000000
}
};
const { allowedExtensions, itemLimit, sizeLimit } = validationParts;
export const validationTypes = {
additionalData: {
itemLimit: itemLimit.multiple,
sizeLimit: sizeLimit.default
},
registerWork: {
itemLimit: itemLimit.single,
sizeLimit: sizeLimit.default
},
workThumbnail: {
itemLimit: itemLimit.single,
sizeLimit: sizeLimit.thumbnail
}
};

View File

@ -40,7 +40,8 @@ const COMMON_ROUTES = (
<Route
path='collection'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(PieceList)}
headerTitle='COLLECTION'/>
headerTitle='COLLECTION'
disableOn='noPieces' />
<Route
path='signup'
component={ProxyHandler(AuthRedirect({to: '/collection', when: 'loggedIn'}))(SignupContainer)} />

View File

@ -78,7 +78,7 @@ export function computeHashOfFile(file) {
progress: start / file.size,
reject
});
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
@ -101,4 +101,4 @@ export function extractFileExtensionFromString(s) {
const explodedFileName = s.split('.');
return explodedFileName.length > 1 ? explodedFileName.pop()
: '';
}
}

View File

@ -8,10 +8,14 @@
},
"scripts": {
"lint": "eslint ./js",
"preinstall": "export SAUCE_CONNECT_DOWNLOAD_ON_INSTALL=true",
"postinstall": "npm run build",
"build": "gulp build --production",
"start": "node server.js",
"test": "mocha",
"tunnel": "node test/tunnel.js"
"vi-clean": "rm -rf gemini-report",
"vi-phantom": "phantomjs --webdriver=4444",
"vi-update": "gemini update",
@ -48,10 +52,16 @@
"devDependencies": {
"babel-eslint": "^3.1.11",
"babel-jest": "^5.2.0",
"chai": "^3.4.1",
"chai-as-promised": "^5.1.0",
"colors": "^1.1.2",
"dotenv": "^1.2.0",
"gemini": "^2.1.0",
"gulp-sass": "^2.1.1",
"jest-cli": "^0.4.0",
"phantomjs2": "^2.0.2"
"mocha": "^2.3.4",
"phantomjs2": "^2.0.2",
"sauce-connect-launcher": "^0.13.0",
"wd": "^0.4.0"
},
"dependencies": {
"alt": "^0.16.5",
@ -77,7 +87,7 @@
"gulp-if": "^1.2.5",
"gulp-minify-css": "^1.1.6",
"gulp-notify": "^2.2.0",
"gulp-sass": "^2.0.1",
"gulp-sass": "^2.1.1",
"gulp-sourcemaps": "^1.5.2",
"gulp-template": "~3.0.0",
"gulp-uglify": "^1.2.0",

View File

@ -7,23 +7,25 @@
position: fixed;
transition: .2s bottom cubic-bezier(.77, 0, .175, 1);
width: 100%;
}
.ascribe-global-notification-off {
bottom: -3.5em;
}
z-index: 9999;
.ascribe-global-notification-on {
bottom: 0;
}
> div {
padding: .5em 1em;
text-align: center;
}
.ascribe-global-notification > div,
.ascribe-global-notification-bubble > div {
display: table-cell;
font-size: 1.25em;
padding-right: 3em;
text-align: right;
vertical-align: middle;
&.ascribe-global-notification-off {
bottom: -5em;
}
&.ascribe-global-notification-on {
bottom: 0;
// React can sometimes take some time to load the notification message,
// so add a small delay before showing the notification
transition-delay: 0.2s;
}
}
.ascribe-global-notification-bubble {
@ -33,21 +35,35 @@
color: white;
display: table;
height: 3.5em;
max-width: 75%;
position: fixed;
right: -50em;
transition: 1s right ease;
transition: 0.5s right ease;
z-index: 9999;
> div {
padding: .75em 1.5em;
text-align: left;
}
&.ascribe-global-notification-bubble-off {
right: -100em;
}
&.ascribe-global-notification-bubble-on {
right: 3.5em;
// React can sometimes take some time to load the notification message,
// so add a small delay before showing the notification
transition-delay: 0.5s;
}
}
.ascribe-global-notification-bubble-off {
right: -100em;
}
.ascribe-global-notification-bubble-on {
right: 3.5em;
.ascribe-global-notification > div,
.ascribe-global-notification-bubble > div {
display: table-cell;
font-size: 1.25em;
vertical-align: middle;
}
.ascribe-global-notification-danger {

View File

@ -9,6 +9,12 @@
text-align: center;
vertical-align: middle;
padding-top: 1.5em;
@media screen and (max-width: 625px) {
.file-name {
display: block;
}
}
}
.inactive-dropzone {
@ -78,12 +84,6 @@
}
}
.file-drag-and-drop-preview-table-wrapper {
display: table;
height: 94px;
width: 104px;
}
.file-drag-and-drop-preview {
background-color: #eeeeee;
border: 1px solid #616161;
@ -131,7 +131,7 @@
width: 104px;
// REFACTOR TO USE TABLE CELL
.action-file, .spinner-file, .icon-ascribe-ok {
.action-file, .spinner-file {
margin-top: 1em;
line-height: 1.3;
}
@ -142,23 +142,21 @@
}
.file-drag-and-drop-preview-other {
display: table-cell;
height: 94px;
text-align: center;
vertical-align: middle;
width: 104px;
p {
margin-top: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-file:not(.icon-ascribe-ok), .spinner-file {
margin-top: 0;
position: relative;
top: .3em;
span:not(:first-child) {
display: block;
margin-top: .5em;
}
top: 0.8em;
}
}

36
test/.eslintrc Normal file
View File

@ -0,0 +1,36 @@
{
"parser": "babel-eslint",
"env": {
"mocha": true,
"node": true
},
"rules": {
"new-cap": [2, {newIsCap: true, capIsNew: false}],
"quotes": [2, "single"],
"eol-last": [0],
"no-mixed-requires": [0],
"no-underscore-dangle": [0],
"global-strict": [2, "always"],
"no-trailing-spaces": [2, { skipBlankLines: true }],
"no-console": 0,
"camelcase": [2, {"properties": "never"}],
},
"globals": {},
"plugins": [],
"ecmaFeatures": {
"modules": 1,
"arrowFunctions",
"classes": 1,
"blockBindings": 1,
"defaultParams": 1,
"destructuring": 1,
"objectLiteralComputedProperties": 1,
"objectLiteralDuplicateProperties": 0,
"objectLiteralShorthandMethods": 1,
"objectLiteralShorthandProperties": 1,
"restParams": 1,
"spread": 1,
"superInFunctions": 1,
"templateStrings": 1
}
}

256
test/README.md Normal file
View File

@ -0,0 +1,256 @@
# TL;DR
Copy the contents of `.env-template` to `.env` and [fill up the missing keys with
information from your SauceLabs account](#how-to-set-up-your-env-config-file).
```bash
$ npm install
$ npm run tunnel
$ npm test && git commit
```
# TODO
* Use gulp to parallelize mocha invocations with different browsers
* Figure out good system for changing subdomain through test scripts
# Welcome to our test suite, let me be your guide
Dear reader, first of all thanks for taking your time reading this document.
The purpose of this document is to give you an overview on what we want to test
and how we are doing it.
# How it works (bird's-eye view)
You will notice that the setup is a bit convoluted. This section will explain
you why. Testing single functions in JavaScript is not that hard (if you don't
need to interact with the DOM), and can be easily achieved using frameworks
like [Mocha](https://mochajs.org/). Integration and cross browser testing is,
on the other side, a huge PITA. Moreover, "browser testing" includes also
"mobile browser testing". On the top of that the same browser (type and
version) can behave in a different way on different operating systems.
To achieve that you can have your own cluster of machines with different
operating systems and browsers or, if you don't want to spend the rest of your
life configuring an average of 100 browsers for each different operating
system, you can pay someone else to do that. Check out [this
article](https://saucelabs.com/selenium/selenium-grid) if you want to know why
using Selenium Grid is better than a DIY approach.
We decided to use [saucelabs](https://saucelabs.com/) cloud (they support [over
700 combinations](https://saucelabs.com/platforms/) of operating systems and
browsers) to run our tests.
## Components and tools
Right now we are just running the test locally, so no Continuous Integration™.
The components involved are:
- **[Selenium WebDriver](https://www.npmjs.com/package/wd)**: it's a library
that can control a browser. You can use the **WebDriver** to load new URLs,
click around, fill out forms, submit forms etc. It's basically a way to
control remotely a browser. The protocol (language agnostic) is called
[JsonWire](https://code.google.com/p/selenium/wiki/JsonWireProtocol), `wd`
wraps it and gives you a nice
[API](https://github.com/admc/wd/blob/master/doc/jsonwire-full-mapping.md)
you can use in JavaScript. There are other implementations in Python, PHP,
Java, etc. Also, a **WebDriver** can be initialized with a list of [desired
capabilities](https://code.google.com/p/selenium/wiki/DesiredCapabilities)
describing which features (like the platform, browser name and version) you
want to use to run your tests.
- **[Selenium Grid](https://github.com/SeleniumHQ/selenium/wiki/Grid2)**: it's
the controller for the cluster of machines/devices that can run browsers.
Selenium Grid is able to scale by distributing tests on several machines,
manage multiple environments from a central point, making it easy to run the
tests against a vast combination of browsers / OS, minimize the maintenance
time for the grid by allowing you to implement custom hooks to leverage
virtual infrastructure for instance.
- **[Saucelabs](https://saucelabs.com/)**: a private company providing a
cluster to run your tests on over 700 combinations of browsers/operating
systems. (They do other things, check out their websites).
- **[SauceConnect](https://wiki.saucelabs.com/display/DOCS/Setting+Up+Sauce+Connect)**:
is a Java software by Saucelabs to connect to your `localhost` to test the
application. There is also a Node.js wrapper
[sauce-connect-launcher](https://www.npmjs.com/package/sauce-connect-launcher),
so you can use it programmatically within your code for tests. Please note
that this module is just a wrapper around the actual software. Running `npm
install` should install the additional Java software as well.
On the JavaScript side, we use:
- [Mocha](https://mochajs.org/): a test framework running on Node.js.
- [chai](http://chaijs.com/): a BDD/TDD assertion library for node that can be
paired with any javascript testing framework.
- [chaiAsPromised](https://github.com/domenic/chai-as-promised/): an extension
for Chai with a fluent language for asserting facts about promises. The
extension is actually quite cool, we can do assertions on promises without
writing callbacks but just chaining operators. Check out their `README` on
GitHub to see an example.
- [dotenv](https://github.com/motdotla/dotenv): a super nice package to load
environment variables from `.env` into `process.env`.
## How to set up your `.env` config file
In the root of this repository there is a file called `.env-template`. Create a
copy and call it `.env`. This file will store some values we need to connect to
Saucelabs.
There are two values to be set:
- `SAUCE_ACCESS_KEY`
- `SAUCE_USERNAME`
The two keys are the [default
ones](https://github.com/admc/wd#environment-variables-for-saucelabs) used by
many products related to Saucelabs. This allow us to keep the configuration
fairly straightforward and simple.
After logging in to https://saucelabs.com/, you can find your **api key** under
the **My Account**. Copy paste the value in your `.env` file.
## Anatomy of a test
First, you need to learn how [Mocha](https://mochajs.org/) works. Brew a coffee
(or tea, if coffee is not your cup of tea), sit down and read the docs.
Done? Great, let's move on and analyze how a test is written.
From a very high level, the flow of a test is the following:
1. load a page with a specific URL
2. do something on the page (click a button, submit a form, etc.)
3. maybe wait some seconds, or wait if something has changed
4. check if the new page contains some text you expect to be there
This is not set in stone, so go crazy if you want. But keep in mind that we
have a one page application, there might be some gotchas on how to wait for
stuff to happen. I suggest you to read the section [Wait for
something](https://github.com/admc/wd#waiting-for-something) to understand
better which tools you have to solve this problem.
Again, take a look to the [`wd` implementation of the JsonWire
protocol](https://github.com/admc/wd/blob/master/doc/jsonwire-full-mapping.md)
to know all the methods you can use to control the browser.
Import the libraries we need.
```javascript
'use strict';
require('dotenv').load();
const wd = require('wd');
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
```
Set up `chai` to use `chaiAsPromised`.
```javascript
chai.use(chaiAsPromised);
chai.should();
```
`browser` is the main object to interact with Saucelab "real" browsers. We will
use this object a lot. It allows us to load pages, click around, check if a
specific text is present, etc.
```javascript
describe('Login logs users in', function() {
let browser;
```
Create the driver to control the browser. `before` will be executed once at the
start of the test before any `it` functions. Use `beforeEach` instead if you'd
like to run some code before each `it` function.
```javascript
before(function() {
browser = wd.promiseChainRemote('ondemand.saucelabs.com', 80);
// Start the browser, go to /login, and wait for the react app to render
return browser
.init({ browserName, version, platform })
.get(config.APP_URL + '/login')
.waitForElementByCss('.ascribe-default-app', asserters.isDisplayed, 10000);
});
```
Close the browser after finishing all tests. `after` will be executed at the end
of all `it` functions. Use `afterEach` instead if you'd like to run some code
after each `it` function.
```javascript
after(function() {
// Destroys the browser session
return browser.quit();
});
```
The actual test. We query the `browser` object to get the title of the page.
Note that `.title()` returns a `promise` **but**, since we are using
`chaiAsPromised`, we have some syntactic sugar to handle the promise in line,
without writing new functions.
```javascript
it('should contain "Log in" in the title', function() {
return browser.title().should.become('Log in');
});
});
```
All together:
```javascript
function testSuite(browserName, version, platform) {
describe(`[${browserName} ${version} ${platform}] Login logs users in`, function() {
// Set timeout to zero so Mocha won't time out.
this.timeout(0);
let browser;
before(function() {
// No need to inject `username` or `access_key`, by default the constructor
// looks up the values in `process.env.SAUCE_USERNAME` and `process.env.SAUCE_ACCESS_KEY`
browser = wd.promiseChainRemote('ondemand.saucelabs.com', 80);
// Start the browser, go to /login, and wait for the react app to render
return browser
.init({ browserName, version, platform })
.get(config.APP_URL + '/login')
.waitForElementByCss('.ascribe-default-app', asserters.isDisplayed, 10000);
});
after(function() {
return browser.quit();
});
it('should contain "Log in" in the title', function() {
return browser.title().should.become('Log in');
});
});
}
```
## How to run the test suite
To run the tests, type:
```bash
$ mocha
```
By default the test suite runs on `http://www.localhost.com:3000/`, if you
want to change the URL, change the `APP_URL` env variable.
# How to have fun
Try this!
```bash
$ mocha -R nyan
```

18
test/config.js Normal file
View File

@ -0,0 +1,18 @@
'use strict';
require('dotenv').load();
// https://code.google.com/p/selenium/wiki/DesiredCapabilities
const BROWSERS = [
'chrome,47,WINDOWS',
'chrome,46,WINDOWS',
'firefox,43,MAC',
'internet explorer,10,VISTA'
];
module.exports = {
BROWSERS: BROWSERS.map(x => x.split(',')),
APP_URL: process.env.SAUCE_DEFAULT_URL || 'http://www.localhost.com:3000'
};

50
test/setup.js Normal file
View File

@ -0,0 +1,50 @@
'use strict';
const config = require('./config');
const colors = require('colors');
const sauceConnectLauncher = require('sauce-connect-launcher');
let globalSauceProcess;
if (!process.env.SAUCE_USERNAME) {
console.log(colors.red('SAUCE_USERNAME is missing. Please check the README.md file.'));
process.exit(1); //eslint-disable-line no-process-exit
}
if (!process.env.SAUCE_ACCESS_KEY) {
console.log(colors.red('SAUCE_ACCESS_KEY is missing. Please check the README.md file.'));
process.exit(1); //eslint-disable-line no-process-exit
}
if (process.env.SAUCE_AUTO_CONNECT) {
before(function(done) {
console.log(colors.yellow('Setting up tunnel from Saucelabs to your lovely computer, will take a while.'));
// Creating the tunnel takes a bit of time. For this case we can safely disable Mocha timeouts.
this.timeout(0);
sauceConnectLauncher(function (err, sauceConnectProcess) {
if (err) {
console.error(err.message);
return;
}
globalSauceProcess = sauceConnectProcess;
done();
});
});
after(function (done) {
// Creating the tunnel takes a bit of time. For this case we can safely disable it.
this.timeout(0);
if (globalSauceProcess) {
globalSauceProcess.close(done);
}
});
} else if (config.APP_URL.match(/localhost/)) {
console.log(colors.yellow(`You are running tests on ${config.APP_URL}, make sure you already have a tunnel running.`));
console.log(colors.yellow('To create the tunnel, run:'));
console.log(colors.yellow(' $ node test/tunnel.js'));
}

50
test/test-login.js Normal file
View File

@ -0,0 +1,50 @@
'use strict';
const Q = require('q');
const wd = require('wd');
const asserters = wd.asserters; // Commonly used asserters for async waits in the browser
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
const config = require('./config.js');
chai.use(chaiAsPromised);
chai.should();
function testSuite(browserName, version, platform) {
describe(`[${browserName} ${version} ${platform}] Login logs users in`, function() {
// Set timeout to zero so Mocha won't time out.
this.timeout(0);
let browser;
before(function() {
// No need to inject `username` or `access_key`, by default the constructor
// looks up the values in `process.env.SAUCE_USERNAME` and `process.env.SAUCE_ACCESS_KEY`
browser = wd.promiseChainRemote('ondemand.saucelabs.com', 80);
// Start the browser, go to /login, and wait for the react app to render
return browser
.configureHttp({ baseUrl: config.APP_URL })
.init({ browserName, version, platform })
.get('/login')
.waitForElementByCss('.ascribe-default-app', asserters.isDisplayed, 10000)
.catch(function (err) {
console.log('Failure -- unable to load app.');
console.log('Skipping tests for this browser...');
return Q.reject(err);
});
});
after(function() {
return browser.quit();
});
it('should contain "Log in" in the title', function() {
return browser.
waitForElementByCss('.ascribe-login-wrapper', asserters.isDisplayed, 2000)
title().should.become('Log in');
});
});
}
config.BROWSERS.map(x => testSuite(...x));

23
test/tunnel.js Normal file
View File

@ -0,0 +1,23 @@
'use strict';
const config = require('./config'); //eslint-disable-line no-unused-vars
const colors = require('colors');
const sauceConnectLauncher = require('sauce-connect-launcher');
function connect() {
console.log(colors.yellow('Setting up tunnel from Saucelabs to your lovely computer, will take a while.'));
// Creating the tunnel takes a bit of time. For this case we can safely disable Mocha timeouts.
sauceConnectLauncher(function (err) {
if (err) {
console.error(err.message);
return;
}
console.log(colors.green('Connected! Keep this process running and execute your tests.'));
});
}
if (require.main === module) {
connect();
}