diff --git a/.gitignore b/.gitignore index f5bf11e8..e497465f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,10 +16,14 @@ webapp-dependencies.txt pids logs results - -node_modules/* -build +build/* + +gemini-coverage/* +gemini-report/* +test/gemini/screenshots/* + +node_modules/* .DS_Store .env diff --git a/README.md b/README.md index 478562d3..855d6d4a 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ Introduction ============ -Onion is the web client for Ascribe. The idea is to have a well documented, -easy to test, easy to hack, JavaScript application. +Onion is the web client for Ascribe. The idea is to have a well documented, modern, easy to test, easy to hack, JavaScript application. -The code is JavaScript ECMA 6. +The code is JavaScript 2015 / ECMAScript 6. Getting started =============== + Install some nice extension for Chrom(e|ium): - [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) - +- [Alt Developer Tools](https://github.com/goatslacker/alt-devtool) + ```bash git clone git@github.com:ascribe/onion.git cd onion @@ -32,22 +33,41 @@ Additionally, to work on the white labeling functionality, you need to edit your 127.0.0.1 lumenus.localhost.com 127.0.0.1 portfolioreview.localhost.com 127.0.0.1 23vivi.localhost.com +127.0.0.1 polline.localhost.com +127.0.0.1 artcity.localhost.com ``` JavaScript Code Conventions =========================== + For this project, we're using: * 4 Spaces -* We use ES6 +* ES6 * We don't use ES6's class declaration for React components because it does not support Mixins as well as Autobinding ([Blog post about it](http://facebook.github.io/react/blog/2015/01/27/react-v0.13.0-beta-1.html#autobinding)) * We don't use camel case for file naming but in everything Javascript related -* We use `let` instead of `var`: [SA Post](http://stackoverflow.com/questions/762011/javascript-let-keyword-vs-var-keyword) -* We don't use Javascript's `Date` object, as its interface introduced bugs previously and we're including `momentjs` for other dependencies anyways +* We use `momentjs` instead of Javascript's `Date` object, as the native `Date` interface previously introduced bugs and we're including `momentjs` for other dependencies anyway + +Make sure to check out the [style guide](https://github.com/ascribe/javascript). + +Linting +------- + +We use [ESLint](https://github.com/eslint/eslint) with our own [custom ruleset](.eslintrc). + + +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: +* [Improving Sass code quality on theguardian.com](https://www.theguardian.com/info/developer-blog/2014/may/13/improving-sass-code-quality-on-theguardiancom) + Branch names -===================== +============ 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: @@ -61,22 +81,21 @@ AD--brief-and-sane-description-of-the-ticket where `brief-and-sane-description-of-the-ticket` does not need to equal to the issue or ticket's title. + Example -------------- +------- + **JIRA ticket name:** `AD-1242 - Frontend caching for simple endpoints to measure perceived page load ` **Github branch name:** `AD-1242-caching-solution-for-stores` -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: -* [Improving Sass code quality on theguardian.com](https://www.theguardian.com/info/developer-blog/2014/may/13/improving-sass-code-quality-on-theguardiancom) - Testing -=============== +======= + +Unit Testing +------------ + We're using Facebook's jest to do testing as it integrates nicely with react.js as well. Tests are always created per directory by creating a `__tests__` folder. To test a specific file, a `_tests.js` file needs to be created. @@ -86,7 +105,24 @@ This is due to the fact that jest's function mocking and ES6 module syntax are [ Therefore, to require a module in your test file, you need to use CommonJS's `require` syntax. Except for this, all tests can be written in ES6 syntax. -## Workflow +Visual Regression Testing +------------------------- + +We're using [Gemini](https://github.com/gemini-testing/gemini) for visual regression tests because it supports both PhantomJS2 and SauceLabs. + +See the [helper docs](test/gemini/README.md) for information on installing Gemini, its dependencies, and running and writing tests. + +Integration Testing +------------------- + +We're using [Sauce Labs](https://saucelabs.com/home) with [WD.js](https://github.com/admc/wd) for integration testing across browser grids with Selenium. + +See the [helper docs](test/integration/README.md) for information on each part of the test stack and how to run and write tests. + + +Workflow +======== + Generally, when you're runing `gulp serve`, all tests are being run. If you want to test exclusively (without having the obnoxious ES6Linter warnings), you can just run `gulp jest:watch`. @@ -137,9 +173,16 @@ A: Easily by starting the your gulp process with the following command: ONION_BASE_URL='/' ONION_SERVER_URL='http://localhost.com:8000/' gulp serve ``` +Or, by adding these two your environment variables: +``` +ONION_BASE_URL='/' +ONION_SERVER_URL='http://localhost.com:8000/' +``` + Q: I want to know all dependencies that get bundled into the live build. A: ```browserify -e js/app.js --list > webapp-dependencies.txt``` + Reading list ============ @@ -152,7 +195,6 @@ Start here - [alt.js](http://alt.js.org/) - [alt.js readme](https://github.com/goatslacker/alt) - Moar stuff ---------- diff --git a/gulpfile.js b/gulpfile.js index f13945b0..afa0d5a9 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -97,7 +97,8 @@ gulp.task('browser-sync', function() { proxy: 'http://localhost:4000', port: 3000, open: false, // does not open the browser-window anymore (handled manually) - ghostMode: false + ghostMode: false, + notify: false // stop showing the browsersync pop up }); }); diff --git a/js/actions/error_queue_actions.js b/js/actions/error_queue_actions.js new file mode 100644 index 00000000..92f77247 --- /dev/null +++ b/js/actions/error_queue_actions.js @@ -0,0 +1,13 @@ +'use strict'; + +import { alt } from '../alt'; + +class ErrorQueueActions { + constructor() { + this.generateActions( + 'shiftErrorQueue' + ); + } +} + +export default alt.createActions(ErrorQueueActions); diff --git a/js/actions/event_actions.js b/js/actions/event_actions.js index 6d8ee12f..24889f4e 100644 --- a/js/actions/event_actions.js +++ b/js/actions/event_actions.js @@ -8,9 +8,8 @@ class EventActions { this.generateActions( 'applicationWillBoot', 'applicationDidBoot', - 'profileDidLoad', - //'userDidLogin', - //'userDidLogout', + 'userDidAuthenticate', + 'userDidLogout', 'routeDidChange' ); } diff --git a/js/actions/user_actions.js b/js/actions/user_actions.js index a661b8de..9d59044f 100644 --- a/js/actions/user_actions.js +++ b/js/actions/user_actions.js @@ -1,6 +1,6 @@ 'use strict'; -import { altUser } from '../alt'; +import { alt } from '../alt'; class UserActions { @@ -15,4 +15,4 @@ class UserActions { } } -export default altUser.createActions(UserActions); +export default alt.createActions(UserActions); diff --git a/js/alt.js b/js/alt.js index 141248c1..3e1d3fae 100644 --- a/js/alt.js +++ b/js/alt.js @@ -4,5 +4,4 @@ import Alt from 'alt'; export let alt = new Alt(); export let altThirdParty = new Alt(); -export let altUser = new Alt(); export let altWhitelabel = new Alt(); diff --git a/js/components/app_base.js b/js/components/app_base.js new file mode 100644 index 00000000..3d14fae5 --- /dev/null +++ b/js/components/app_base.js @@ -0,0 +1,90 @@ +'use strict'; + +import React from 'react'; +import classNames from 'classnames'; +import { History } from 'react-router'; + +import UserActions from '../actions/user_actions'; +import UserStore from '../stores/user_store'; + +import WhitelabelActions from '../actions/whitelabel_actions'; +import WhitelabelStore from '../stores/whitelabel_store'; + +import GlobalNotification from './global_notification'; + +import AppConstants from '../constants/application_constants'; + +import { mergeOptions } from '../utils/general_utils'; + + +export default function AppBase(App) { + return React.createClass({ + displayName: 'AppBase', + + propTypes: { + children: React.PropTypes.element.isRequired, + history: React.PropTypes.object.isRequired, + location: React.PropTypes.object.isRequired, + routes: React.PropTypes.arrayOf(React.PropTypes.object).isRequired + }, + + getInitialState() { + return mergeOptions( + UserStore.getState(), + WhitelabelStore.getState() + ); + }, + + mixins: [History], + + componentDidMount() { + UserStore.listen(this.onChange); + WhitelabelStore.listen(this.onChange); + + UserActions.fetchCurrentUser(); + WhitelabelActions.fetchWhitelabel(); + + this.history.locationQueue.push(this.props.location); + }, + + componentWillReceiveProps(nextProps) { + const { locationQueue } = this.history; + locationQueue.unshift(nextProps.location); + + // Limit the number of locations to keep in memory to avoid too much memory usage + if (locationQueue.length > AppConstants.locationThreshold) { + locationQueue.length = AppConstants.locationThreshold; + } + }, + + componentWillUnmount() { + UserStore.unlisten(this.onChange); + WhitelabelActions.unlisten(this.onChange); + }, + + onChange(state) { + this.setState(state); + }, + + render() { + const { routes } = this.props; + const { currentUser, whitelabel } = this.state; + + // The second element of the routes prop given to us by react-router is always the + // active second-level component object (ie. after App). + const activeRoute = routes[1]; + + return ( +
+ + + + ); + } + }); +}; diff --git a/js/components/app_route_wrapper.js b/js/components/app_route_wrapper.js new file mode 100644 index 00000000..d680faeb --- /dev/null +++ b/js/components/app_route_wrapper.js @@ -0,0 +1,34 @@ +'use strict'; + +import React from 'react'; + +import { omitFromObject } from '../utils/general_utils'; + +const AppRouteWrapper = React.createClass({ + propTypes: { + children: React.PropTypes.oneOfType([ + React.PropTypes.arrayOf(React.PropTypes.element), + React.PropTypes.element + ]).isRequired + }, + + render() { + const propsToPropagate = omitFromObject(this.props, ['children']); + + let childrenWithProps = this.props.children; + // If there are more props given, propagate them into the child routes by cloning the routes + if (Object.keys(propsToPropagate).length) { + childrenWithProps = React.Children.map(this.props.children, (child) => { + return React.cloneElement(child, propsToPropagate); + }); + } + + return ( +
+ {childrenWithProps} +
+ ); + } +}); + +export default AppRouteWrapper; diff --git a/js/components/ascribe_accordion_list/accordion_list.js b/js/components/ascribe_accordion_list/accordion_list.js index 1046ab7f..1ce113cb 100644 --- a/js/components/ascribe_accordion_list/accordion_list.js +++ b/js/components/ascribe_accordion_list/accordion_list.js @@ -1,14 +1,17 @@ 'use strict'; import React from 'react'; +import { Link } from 'react-router'; + import { getLangText } from '../../utils/lang_utils'; let AccordionList = React.createClass({ propTypes: { - className: React.PropTypes.string, children: React.PropTypes.arrayOf(React.PropTypes.element).isRequired, - loadingElement: React.PropTypes.element, + loadingElement: React.PropTypes.element.isRequired, + + className: React.PropTypes.string, count: React.PropTypes.number, itemList: React.PropTypes.arrayOf(React.PropTypes.object), search: React.PropTypes.string, @@ -22,7 +25,7 @@ let AccordionList = React.createClass({ render() { const { search } = this.props; - if(this.props.itemList && this.props.itemList.length > 0) { + if (this.props.itemList && this.props.itemList.length > 0) { return (
{this.props.children} @@ -36,7 +39,7 @@ let AccordionList = React.createClass({

{getLangText('To register one, click')}  - {getLangText('here')}! + {getLangText('here')}!

); diff --git a/js/components/ascribe_accordion_list/accordion_list_item.js b/js/components/ascribe_accordion_list/accordion_list_item.js index 38cb77b1..4cc99de0 100644 --- a/js/components/ascribe_accordion_list/accordion_list_item.js +++ b/js/components/ascribe_accordion_list/accordion_list_item.js @@ -21,16 +21,16 @@ let AccordionListItem = React.createClass({ }, render() { - const { linkData, - className, - thumbnail, - heading, - subheading, - subsubheading, - buttons, - badge, - children } = this.props; - + const { + linkData, + className, + thumbnail, + heading, + subheading, + subsubheading, + buttons, + badge, + children } = this.props; return (
diff --git a/js/components/ascribe_accordion_list/accordion_list_item_piece.js b/js/components/ascribe_accordion_list/accordion_list_item_piece.js index 9f876388..3a21c2ea 100644 --- a/js/components/ascribe_accordion_list/accordion_list_item_piece.js +++ b/js/components/ascribe_accordion_list/accordion_list_item_piece.js @@ -34,11 +34,10 @@ let AccordionListItemPiece = React.createClass({ }, getLinkData() { - let { piece } = this.props; + const { piece } = this.props; - if(piece && piece.first_edition) { + if (piece && piece.first_edition) { return `/editions/${piece.first_edition.bitcoin_id}`; - } else { return `/pieces/${piece.id}`; } diff --git a/js/components/ascribe_accordion_list/accordion_list_item_thumbnail_placeholder.js b/js/components/ascribe_accordion_list/accordion_list_item_thumbnail_placeholder.js index 37c98371..8000affd 100644 --- a/js/components/ascribe_accordion_list/accordion_list_item_thumbnail_placeholder.js +++ b/js/components/ascribe_accordion_list/accordion_list_item_thumbnail_placeholder.js @@ -1,4 +1,4 @@ -'use strict' +'use strict'; import React from 'react'; diff --git a/js/components/ascribe_accordion_list/accordion_list_item_wallet.js b/js/components/ascribe_accordion_list/accordion_list_item_wallet.js index f6712d37..e752451e 100644 --- a/js/components/ascribe_accordion_list/accordion_list_item_wallet.js +++ b/js/components/ascribe_accordion_list/accordion_list_item_wallet.js @@ -7,20 +7,19 @@ import Glyphicon from 'react-bootstrap/lib/Glyphicon'; import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger'; import Tooltip from 'react-bootstrap/lib/Tooltip'; -import AccordionListItemPiece from './accordion_list_item_piece'; -import AccordionListItemEditionWidget from './accordion_list_item_edition_widget'; -import CreateEditionsForm from '../ascribe_forms/create_editions_form'; - -import PieceListActions from '../../actions/piece_list_actions'; -import PieceListStore from '../../stores/piece_list_store'; - -import WhitelabelStore from '../../stores/whitelabel_store'; - import EditionListActions from '../../actions/edition_list_actions'; import GlobalNotificationModel from '../../models/global_notification_model'; import GlobalNotificationActions from '../../actions/global_notification_actions'; +import PieceListActions from '../../actions/piece_list_actions'; +import PieceListStore from '../../stores/piece_list_store'; + +import AccordionListItemPiece from './accordion_list_item_piece'; +import AccordionListItemEditionWidget from './accordion_list_item_edition_widget'; +import CreateEditionsForm from '../ascribe_forms/create_editions_form'; + + import AclProxy from '../acl_proxy'; import { getLangText } from '../../utils/lang_utils'; @@ -29,50 +28,51 @@ import { mergeOptions } from '../../utils/general_utils'; let AccordionListItemWallet = React.createClass({ propTypes: { - className: React.PropTypes.string, - content: React.PropTypes.object, - thumbnailPlaceholder: React.PropTypes.func, + content: React.PropTypes.object.isRequired, + whitelabel: React.PropTypes.object.isRequired, + children: React.PropTypes.oneOfType([ React.PropTypes.arrayOf(React.PropTypes.element), React.PropTypes.element - ]) + ]), + className: React.PropTypes.string, + thumbnailPlaceholder: React.PropTypes.func }, getInitialState() { return mergeOptions( + PieceListStore.getState(), { showCreateEditionsDialog: false - }, - PieceListStore.getState(), - WhitelabelStore.getState() + } ); }, componentDidMount() { PieceListStore.listen(this.onChange); - WhitelabelStore.listen(this.onChange); }, componentWillUnmount() { PieceListStore.unlisten(this.onChange); - WhitelabelStore.unlisten(this.onChange); }, onChange(state) { this.setState(state); }, - getGlyphicon(){ - if ((this.props.content.notifications && this.props.content.notifications.length > 0)){ + getGlyphicon() { + if (this.props.content.notifications && this.props.content.notifications.length) { return ( {getLangText('You have actions pending')}}> - - ); + + + ); + } else { + return null; } - return null; }, toggleCreateEditionsDialog() { @@ -93,7 +93,7 @@ let AccordionListItemWallet = React.createClass({ PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy }); EditionListActions.toggleEditionList(pieceId); - const notification = new GlobalNotificationModel('Editions successfully created', 'success', 10000); + const notification = new GlobalNotificationModel(getLangText('Editions successfully created'), 'success', 10000); GlobalNotificationActions.appendGlobalNotification(notification); }, @@ -111,13 +111,15 @@ let AccordionListItemWallet = React.createClass({ }, getLicences() { + const { content, whitelabel } = this.props; + // convert this to acl_view_licences later - if (this.state.whitelabel && this.state.whitelabel.name === 'Creative Commons France') { + if (whitelabel.name === 'Creative Commons France') { return ( , - - {getLangText('%s license', this.props.content.license_type.code)} + + {getLangText('%s license', content.license_type.code)} ); diff --git a/js/components/ascribe_app.js b/js/components/ascribe_app.js index cda5637f..87ab1daf 100644 --- a/js/components/ascribe_app.js +++ b/js/components/ascribe_app.js @@ -2,36 +2,42 @@ import React from 'react'; -import Header from '../components/header'; -import Footer from '../components/footer'; -import GlobalNotification from './global_notification'; +import AppBase from './app_base'; +import AppRouteWrapper from './app_route_wrapper'; +import Footer from './footer'; +import Header from './header'; let AscribeApp = React.createClass({ propTypes: { - children: React.PropTypes.oneOfType([ - React.PropTypes.arrayOf(React.PropTypes.element), - React.PropTypes.element - ]), - routes: React.PropTypes.arrayOf(React.PropTypes.object) + activeRoute: React.PropTypes.object.isRequired, + children: React.PropTypes.element.isRequired, + routes: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + + // Provided from AppBase + currentUser: React.PropTypes.object, + whitelabel: React.PropTypes.object }, render() { - let { children, routes } = this.props; + const { activeRoute, children, currentUser, routes, whitelabel } = this.props; return ( -
-
- {/* Routes are injected here */} -
+
+
+ + {/* Routes are injected here */} {children} -
-
- - + +
); } }); -export default AscribeApp; +export default AppBase(AscribeApp); diff --git a/js/components/ascribe_buttons/acl_button_list.js b/js/components/ascribe_buttons/acl_button_list.js index 35e42c20..d059e0f2 100644 --- a/js/components/ascribe_buttons/acl_button_list.js +++ b/js/components/ascribe_buttons/acl_button_list.js @@ -2,9 +2,6 @@ import React from 'react/addons'; -import UserActions from '../../actions/user_actions'; -import UserStore from '../../stores/user_store'; - import ConsignButton from './acls/consign_button'; import EmailButton from './acls/email_button'; import LoanButton from './acls/loan_button'; @@ -12,50 +9,44 @@ import LoanRequestButton from './acls/loan_request_button'; import TransferButton from './acls/transfer_button'; import UnconsignButton from './acls/unconsign_button'; -import { mergeOptions } from '../../utils/general_utils'; +import { selectFromObject } from '../../utils/general_utils'; let AclButtonList = React.createClass({ propTypes: { - className: React.PropTypes.string, + availableAcls: React.PropTypes.object.isRequired, + currentUser: React.PropTypes.object.isRequired, + handleSuccess: React.PropTypes.func.isRequired, pieceOrEditions: React.PropTypes.oneOfType([ React.PropTypes.object, React.PropTypes.array ]).isRequired, - availableAcls: React.PropTypes.object.isRequired, + buttonsStyle: React.PropTypes.object, - handleSuccess: React.PropTypes.func.isRequired, children: React.PropTypes.oneOfType([ React.PropTypes.arrayOf(React.PropTypes.element), React.PropTypes.element - ]) + ]), + className: React.PropTypes.string }, getInitialState() { - return mergeOptions( - UserStore.getState(), - { - buttonListSize: 0 - } - ); + return { + buttonListSize: 0 + } }, componentDidMount() { - UserStore.listen(this.onChange); - UserActions.fetchCurrentUser.defer(); - window.addEventListener('resize', this.handleResize); window.dispatchEvent(new Event('resize')); }, componentDidUpdate(prevProps) { - if(prevProps.availableAcls && prevProps.availableAcls !== this.props.availableAcls) { + if (prevProps.availableAcls && prevProps.availableAcls !== this.props.availableAcls) { window.dispatchEvent(new Event('resize')); } }, componentWillUnmount() { - UserStore.unlisten(this.onChange); - window.removeEventListener('resize', this.handleResize); }, @@ -65,10 +56,6 @@ let AclButtonList = React.createClass({ }); }, - onChange(state) { - this.setState(state); - }, - renderChildren() { const { children } = this.props; const { buttonListSize } = this.state; @@ -79,42 +66,29 @@ let AclButtonList = React.createClass({ }, render() { - const { className, - buttonsStyle, - availableAcls, - pieceOrEditions, - handleSuccess } = this.props; + const { + availableAcls, + buttonsStyle, + className, + currentUser, + handleSuccess, + pieceOrEditions } = this.props; - const { currentUser } = this.state; + const buttonProps = selectFromObject(this.props, [ + 'availableAcls', + 'currentUser', + 'handleSuccess', + 'pieceOrEditions' + ]); return (
- - - - - + + + + + {this.renderChildren()}
diff --git a/js/components/ascribe_buttons/acls/acl_button.js b/js/components/ascribe_buttons/acls/acl_button.js index 97f2e173..d40a779a 100644 --- a/js/components/ascribe_buttons/acls/acl_button.js +++ b/js/components/ascribe_buttons/acls/acl_button.js @@ -14,7 +14,7 @@ import AppConstants from '../../../constants/application_constants'; import { AclInformationText } from '../../../constants/acl_information_text'; -export default function ({ action, displayName, title, tooltip }) { +export default function AclButton({ action, displayName, title, tooltip }) { if (AppConstants.aclList.indexOf(action) < 0) { console.warn('Your specified aclName did not match a an acl class.'); } @@ -24,23 +24,20 @@ export default function ({ action, displayName, title, tooltip }) { propTypes: { availableAcls: React.PropTypes.object.isRequired, - buttonAcceptName: React.PropTypes.string, - buttonAcceptClassName: React.PropTypes.string, - currentUser: React.PropTypes.object, - email: React.PropTypes.string, pieceOrEditions: React.PropTypes.oneOfType([ React.PropTypes.object, React.PropTypes.array ]).isRequired, - handleSuccess: React.PropTypes.func.isRequired, - className: React.PropTypes.string + + buttonAcceptName: React.PropTypes.string, + buttonAcceptClassName: React.PropTypes.string, + currentUser: React.PropTypes.object, + email: React.PropTypes.string, + handleSuccess: React.PropTypes.func }, sanitizeAction() { - if (this.props.buttonAcceptName) { - return this.props.buttonAcceptName; - } - return AclInformationText.titles[action]; + return this.props.buttonAcceptName || AclInformationText.titles[action]; }, render() { diff --git a/js/components/ascribe_buttons/unconsign_request_button.js b/js/components/ascribe_buttons/unconsign_request_button.js index e5e1c661..c324ff28 100644 --- a/js/components/ascribe_buttons/unconsign_request_button.js +++ b/js/components/ascribe_buttons/unconsign_request_button.js @@ -15,10 +15,12 @@ let UnConsignRequestButton = React.createClass({ propTypes: { currentUser: React.PropTypes.object.isRequired, edition: React.PropTypes.object.isRequired, - handleSuccess: React.PropTypes.func.isRequired + + handleSuccess: React.PropTypes.func }, render: function () { + const { currentUser, edition, handleSuccess } = this.props; return ( } - handleSuccess={this.props.handleSuccess} + handleSuccess={handleSuccess} title='Request to Un-Consign'> +${currentUser.username}` + } /> ); } diff --git a/js/components/ascribe_detail/edition.js b/js/components/ascribe_detail/edition.js index df9d41a0..f2110e10 100644 --- a/js/components/ascribe_detail/edition.js +++ b/js/components/ascribe_detail/edition.js @@ -8,23 +8,23 @@ import Row from 'react-bootstrap/lib/Row'; import Col from 'react-bootstrap/lib/Col'; import Glyphicon from 'react-bootstrap/lib/Glyphicon'; -import HistoryIterator from './history_iterator'; +import EditionActions from '../../actions/edition_actions'; -import MediaContainer from './media_container'; - -import CollapsibleParagraph from './../ascribe_collapsible/collapsible_paragraph'; - -import Form from './../ascribe_forms/form'; -import Property from './../ascribe_forms/property'; import DetailProperty from './detail_property'; -import LicenseDetail from './license_detail'; -import FurtherDetails from './further_details'; - import EditionActionPanel from './edition_action_panel'; -import AclProxy from '../acl_proxy'; - +import FurtherDetails from './further_details'; +import HistoryIterator from './history_iterator'; +import LicenseDetail from './license_detail'; +import MediaContainer from './media_container'; import Note from './note'; +import CollapsibleParagraph from '../ascribe_collapsible/collapsible_paragraph'; + +import Form from '../ascribe_forms/form'; +import Property from '../ascribe_forms/property'; + +import AclProxy from '../acl_proxy'; + import ApiUrls from '../../constants/api_urls'; import AscribeSpinner from '../ascribe_spinner'; @@ -36,11 +36,13 @@ import { getLangText } from '../../utils/lang_utils'; */ let Edition = React.createClass({ propTypes: { + currentUser: React.PropTypes.object.isRequired, + edition: React.PropTypes.object.isRequired, + whitelabel: React.PropTypes.object.isRequired, + actionPanelButtonListType: React.PropTypes.func, - furtherDetailsType: React.PropTypes.func, - edition: React.PropTypes.object, coaError: React.PropTypes.object, - currentUser: React.PropTypes.object, + furtherDetailsType: React.PropTypes.func, loadEdition: React.PropTypes.func }, @@ -57,56 +59,56 @@ let Edition = React.createClass({ currentUser, edition, furtherDetailsType: FurtherDetailsType, - loadEdition } = this.props; + loadEdition, + whitelabel } = this.props; return ( + currentUser={currentUser} + refreshObject={loadEdition} />
-
+

{edition.title}

-
+
+ handleSuccess={loadEdition} + whitelabel={whitelabel} /> + editionId={edition.bitcoin_id} /> 0}> - + show={edition.ownership_history && edition.ownership_history.length}> + 0}> - + 0}> - + + currentUser={currentUser} /> {return {'bitcoin_id': edition.bitcoin_id}; }} label={getLangText('Personal note (public)')} @@ -130,13 +132,11 @@ let Edition = React.createClass({ show={!!edition.public_note || !!edition.acl.acl_edit} successMessage={getLangText('Public edition note saved')} url={ApiUrls.note_public_edition} - currentUser={currentUser}/> + currentUser={currentUser} /> 0 || - edition.other_data.length > 0}> + show={edition.acl.acl_edit || Object.keys(edition.extra_data).length || edition.other_data.length}> - - + +
@@ -158,60 +156,56 @@ let Edition = React.createClass({ let EditionSummary = React.createClass({ propTypes: { + currentUser: React.PropTypes.object.isRequired, + edition: React.PropTypes.object.isRequired, + whitelabel: React.PropTypes.object.isRequired, + actionPanelButtonListType: React.PropTypes.func, - edition: React.PropTypes.object, - currentUser: React.PropTypes.object, handleSuccess: React.PropTypes.func }, - handleSuccess() { - this.props.handleSuccess(); - }, + getStatus() { + const { status } = this.props.edition; - getStatus(){ - let status = null; - if (this.props.edition.status.length > 0){ - let statusStr = this.props.edition.status.join(', ').replace(/_/g, ' '); - status = ; - if (this.props.edition.pending_new_owner && this.props.edition.acl.acl_withdraw_transfer){ - status = ( - - ); - } - } - return status; + return status.length ? ( + + ) : null; }, render() { - let { actionPanelButtonListType, edition, currentUser } = this.props; + const { actionPanelButtonListType, currentUser, edition, handleSuccess, whitelabel } = this.props; + return (
+ value={edition.edition_number + ' ' + getLangText('of') + ' ' + edition.num_editions} /> - + value={edition.owner} /> + {this.getStatus()} {/* `acl_view` is always available in `edition.acl`, therefore if it has no more than 1 key, we're hiding the `DetailProperty` actions as otherwise `AclInformation` would show up */} - 1}> + 1}> + edition={edition} + handleSuccess={handleSuccess} + whitelabel={whitelabel} />
@@ -360,4 +354,5 @@ let SpoolDetails = React.createClass({ } }); + export default Edition; diff --git a/js/components/ascribe_detail/edition_action_panel.js b/js/components/ascribe_detail/edition_action_panel.js index 71bf38fe..dd5c117b 100644 --- a/js/components/ascribe_detail/edition_action_panel.js +++ b/js/components/ascribe_detail/edition_action_panel.js @@ -36,9 +36,11 @@ import { getLangText } from '../../utils/lang_utils'; */ let EditionActionPanel = React.createClass({ propTypes: { + currentUser: React.PropTypes.object.isRequired, + edition: React.PropTypes.object.isRequired, + whitelabel: React.PropTypes.object.isRequired, + actionPanelButtonListType: React.PropTypes.func, - edition: React.PropTypes.object, - currentUser: React.PropTypes.object, handleSuccess: React.PropTypes.func }, @@ -87,39 +89,42 @@ let EditionActionPanel = React.createClass({ handleSuccess(response) { this.refreshCollection(); - this.props.handleSuccess(); - if (response){ - let notification = new GlobalNotificationModel(response.notification, 'success'); + + if (response) { + const notification = new GlobalNotificationModel(response.notification, 'success'); GlobalNotificationActions.appendGlobalNotification(notification); } + + if (typeof this.props.handleSuccess === 'function') { + this.props.handleSuccess(); + } }, render() { const { actionPanelButtonListType: ActionPanelButtonListType, + currentUser, edition, - currentUser } = this.props; + whitelabel } = this.props; - if (edition && - edition.notifications && - edition.notifications.length > 0){ + if (edition.notifications && edition.notifications.length) { return ( ); - } - - else { + notifications={edition.notifications} + pieceOrEditions={[edition]} + handleSuccess={this.handleSuccess} />); + } else { return ( + whitelabel={whitelabel}> diff --git a/js/components/ascribe_detail/edition_container.js b/js/components/ascribe_detail/edition_container.js index d0adadf0..77ebed48 100644 --- a/js/components/ascribe_detail/edition_container.js +++ b/js/components/ascribe_detail/edition_container.js @@ -9,16 +9,12 @@ import { ResourceNotFoundError } from '../../models/errors'; import EditionActions from '../../actions/edition_actions'; import EditionStore from '../../stores/edition_store'; -import UserActions from '../../actions/user_actions'; -import UserStore from '../../stores/user_store'; - import Edition from './edition'; import AscribeSpinner from '../ascribe_spinner'; import { getLangText } from '../../utils/lang_utils'; import { setDocumentTitle } from '../../utils/dom_utils'; -import { mergeOptions } from '../../utils/general_utils'; /** @@ -28,24 +24,26 @@ let EditionContainer = React.createClass({ propTypes: { actionPanelButtonListType: React.PropTypes.func, furtherDetailsType: React.PropTypes.func, + + // Provided from AscribeApp + currentUser: React.PropTypes.object.isRequired, + whitelabel: React.PropTypes.object.isRequired, + + // Provided from router + location: React.PropTypes.object, params: React.PropTypes.object }, mixins: [History, ReactError], getInitialState() { - return mergeOptions( - EditionStore.getInitialState(), - UserStore.getState() - ); + return EditionStore.getInitialState(); }, componentDidMount() { EditionStore.listen(this.onChange); - UserStore.listen(this.onChange); this.loadEdition(); - UserActions.fetchCurrentUser(); }, // This is done to update the container when the user clicks on the prev or next @@ -68,19 +66,10 @@ let EditionContainer = React.createClass({ componentWillUnmount() { window.clearInterval(this.state.timerId); EditionStore.unlisten(this.onChange); - UserStore.unlisten(this.onChange); }, onChange(state) { this.setState(state); - - if(state && state.edition && state.edition.digital_work) { - let isEncoding = state.edition.digital_work.isEncoding; - if (state.edition.digital_work.mime === 'video' && typeof isEncoding === 'number' && isEncoding !== 100 && !this.state.timerId) { - let timerId = window.setInterval(() => EditionActions.fetchEdition(this.props.params.editionId), 10000); - this.setState({timerId: timerId}); - } - } }, loadEdition(editionId = this.props.params.editionId) { @@ -88,8 +77,8 @@ let EditionContainer = React.createClass({ }, render() { - const { edition, currentUser, coaMeta } = this.state; - const { actionPanelButtonListType, furtherDetailsType } = this.props; + const { actionPanelButtonListType, currentUser, furtherDetailsType, whitelabel } = this.props; + const { edition, coaMeta } = this.state; if (edition.id) { setDocumentTitle(`${edition.artist_name}, ${edition.title}`); @@ -97,11 +86,12 @@ let EditionContainer = React.createClass({ return ( + edition={edition} + furtherDetailsType={furtherDetailsType} + loadEdition={this.loadEdition} + whitelabel={whitelabel} /> ); } else { return ( diff --git a/js/components/ascribe_detail/further_details_fileuploader.js b/js/components/ascribe_detail/further_details_fileuploader.js index 61e0724f..56619c0a 100644 --- a/js/components/ascribe_detail/further_details_fileuploader.js +++ b/js/components/ascribe_detail/further_details_fileuploader.js @@ -14,19 +14,24 @@ import { getCookie } from '../../utils/fetch_api_utils'; import { getLangText } from '../../utils/lang_utils'; +const { func, bool, number, object, string, arrayOf } = React.PropTypes; + let FurtherDetailsFileuploader = React.createClass({ propTypes: { - pieceId: React.PropTypes.number.isRequired, + pieceId: number.isRequired, - areAssetsDownloadable: React.PropTypes.bool, - editable: React.PropTypes.bool, - isReadyForFormSubmission: React.PropTypes.func, - label: React.PropTypes.string, - multiple: React.PropTypes.bool, - otherData: React.PropTypes.arrayOf(React.PropTypes.object), - onValidationFailed: React.PropTypes.func, - setIsUploadReady: React.PropTypes.func, - submitFile: React.PropTypes.func, + editable: bool, + label: string, + otherData: arrayOf(object), + + // Props for ReactS3FineUploader + areAssetsDownloadable: bool, + isReadyForFormSubmission: func, + submitFile: func, // TODO: rename to onSubmitFile + onValidationFailed: func, + multiple: bool, + setIsUploadReady: func, //TODO: rename to setIsUploaderValidated + showErrorPrompt: bool, validation: ReactS3FineUploader.propTypes.validation }, @@ -40,36 +45,57 @@ let FurtherDetailsFileuploader = React.createClass({ }, render() { + const { + editable, + isReadyForFormSubmission, + multiple, + onValidationFailed, + otherData, + pieceId, + setIsUploadReady, + showErrorPrompt, + submitFile, + validation } = this.props; + // Essentially there a three cases important to the fileuploader // // 1. there is no other_data => do not show the fileuploader at all (where otherData is now an array) // 2. there is other_data, but user has no edit rights => show fileuploader but without action buttons // 3. both other_data and editable are defined or true => show fileuploader with all action buttons - if (!this.props.editable && (!this.props.otherData || this.props.otherData.length === 0)) { + if (!editable && (!otherData || otherData.length === 0)) { return null; } - let otherDataIds = this.props.otherData ? this.props.otherData.map((data) => data.id).join() : null; + let otherDataIds = otherData ? otherData.map((data) => data.id).join() : null; return ( + submitFile={submitFile} + showErrorPrompt={showErrorPrompt} + validation={validation} /> ); } diff --git a/js/components/ascribe_detail/media_container.js b/js/components/ascribe_detail/media_container.js index 00ca9164..1e9ba0a1 100644 --- a/js/components/ascribe_detail/media_container.js +++ b/js/components/ascribe_detail/media_container.js @@ -22,12 +22,14 @@ const EMBED_IFRAME_HEIGHT = { video: 315, audio: 62 }; +const ENCODE_UPDATE_TIME = 5000; let MediaContainer = React.createClass({ propTypes: { - content: React.PropTypes.object, - currentUser: React.PropTypes.object, - refreshObject: React.PropTypes.func + content: React.PropTypes.object.isRequired, + refreshObject: React.PropTypes.func.isRequired, + + currentUser: React.PropTypes.object }, getInitialState() { @@ -37,14 +39,16 @@ let MediaContainer = React.createClass({ }, componentDidMount() { - if (!this.props.content.digital_work) { - return; - } + const { content: { digital_work: digitalWork }, refreshObject } = this.props; - const isEncoding = this.props.content.digital_work.isEncoding; - if (this.props.content.digital_work.mime === 'video' && typeof isEncoding === 'number' && isEncoding !== 100 && !this.state.timerId) { - let timerId = window.setInterval(this.props.refreshObject, 10000); - this.setState({timerId: timerId}); + if (digitalWork) { + const isEncoding = digitalWork.isEncoding; + + if (digitalWork.mime === 'video' && typeof isEncoding === 'number' && isEncoding !== 100 && !this.state.timerId) { + this.setState({ + timerId: window.setInterval(refreshObject, ENCODE_UPDATE_TIME) + }); + } } }, @@ -105,7 +109,7 @@ let MediaContainer = React.createClass({ {''} - }/> + } /> ); } return ( @@ -136,7 +140,7 @@ let MediaContainer = React.createClass({ If it turns out that `fileExtension` is an empty string, we're just using the label 'file'. */} - {getLangText('Download')} .{fileExtension || 'file'} + {getLangText('Download')} .{fileExtension || 'file'} {embed} diff --git a/js/components/ascribe_detail/note.js b/js/components/ascribe_detail/note.js index c739b937..693a0400 100644 --- a/js/components/ascribe_detail/note.js +++ b/js/components/ascribe_detail/note.js @@ -2,64 +2,68 @@ import React from 'react'; +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 GlobalNotificationModel from '../../models/global_notification_model'; -import GlobalNotificationActions from '../../actions/global_notification_actions'; - import { getLangText } from '../../utils/lang_utils'; let Note = React.createClass({ propTypes: { - url: React.PropTypes.string, - id: React.PropTypes.func, - label: React.PropTypes.string, - currentUser: React.PropTypes.object, + currentUser: React.PropTypes.object.isRequired, + id: React.PropTypes.func.isRequired, + url: React.PropTypes.string.isRequired, + defaultValue: React.PropTypes.string, editable: React.PropTypes.bool, - show: React.PropTypes.bool, + label: React.PropTypes.string, placeholder: React.PropTypes.string, + show: React.PropTypes.bool, successMessage: React.PropTypes.string }, getDefaultProps() { return { editable: true, - show: true, placeholder: getLangText('Enter a note'), + show: true, successMessage: getLangText('Note saved') }; }, - showNotification(){ - let notification = new GlobalNotificationModel(this.props.successMessage, 'success'); + showNotification() { + const notification = new GlobalNotificationModel(this.props.successMessage, 'success'); GlobalNotificationActions.appendGlobalNotification(notification); }, render() { - if ((!!this.props.currentUser.username && this.props.editable || !this.props.editable ) && this.props.show) { + const { currentUser, defaultValue, editable, id, label, placeholder, show, url } = this.props; + + if ((!!currentUser.username && editable || !editable ) && show) { return (
+ disabled={!editable}> + label={label}> + defaultValue={defaultValue} + placeholder={placeholder} />
); + } else { + return null; } - return null; } }); -export default Note; \ No newline at end of file +export default Note; diff --git a/js/components/ascribe_detail/piece.js b/js/components/ascribe_detail/piece.js index e4ff4ea7..9864cf95 100644 --- a/js/components/ascribe_detail/piece.js +++ b/js/components/ascribe_detail/piece.js @@ -15,19 +15,19 @@ import MediaContainer from './media_container'; */ let Piece = React.createClass({ propTypes: { - piece: React.PropTypes.object, + piece: React.PropTypes.object.isRequired, + + buttons: React.PropTypes.object, currentUser: React.PropTypes.object, header: React.PropTypes.object, subheader: React.PropTypes.object, - buttons: React.PropTypes.object, children: React.PropTypes.oneOfType([ React.PropTypes.arrayOf(React.PropTypes.element), React.PropTypes.element ]) }, - - updateObject() { + updatePiece() { return PieceActions.fetchPiece(this.props.piece.id); }, @@ -40,7 +40,7 @@ let Piece = React.createClass({ + refreshObject={this.updatePiece} /> {header} diff --git a/js/components/ascribe_detail/piece_container.js b/js/components/ascribe_detail/piece_container.js index 8ee3111f..0f9df8f6 100644 --- a/js/components/ascribe_detail/piece_container.js +++ b/js/components/ascribe_detail/piece_container.js @@ -7,39 +7,35 @@ import Moment from 'moment'; import ReactError from '../../mixins/react_error'; import { ResourceNotFoundError } from '../../models/errors'; +import EditionListActions from '../../actions/edition_list_actions'; + +import GlobalNotificationModel from '../../models/global_notification_model'; +import GlobalNotificationActions from '../../actions/global_notification_actions'; + import PieceActions from '../../actions/piece_actions'; import PieceStore from '../../stores/piece_store'; import PieceListActions from '../../actions/piece_list_actions'; import PieceListStore from '../../stores/piece_list_store'; -import UserActions from '../../actions/user_actions'; -import UserStore from '../../stores/user_store'; - -import EditionListActions from '../../actions/edition_list_actions'; - -import Piece from './piece'; -import CollapsibleParagraph from './../ascribe_collapsible/collapsible_paragraph'; import FurtherDetails from './further_details'; - import DetailProperty from './detail_property'; -import LicenseDetail from './license_detail'; import HistoryIterator from './history_iterator'; +import LicenseDetail from './license_detail'; +import Note from './note'; +import Piece from './piece'; import AclButtonList from './../ascribe_buttons/acl_button_list'; -import CreateEditionsForm from '../ascribe_forms/create_editions_form'; +import AclInformation from '../ascribe_buttons/acl_information'; import CreateEditionsButton from '../ascribe_buttons/create_editions_button'; import DeleteButton from '../ascribe_buttons/delete_button'; -import AclInformation from '../ascribe_buttons/acl_information'; -import AclProxy from '../acl_proxy'; +import CollapsibleParagraph from './../ascribe_collapsible/collapsible_paragraph'; +import CreateEditionsForm from '../ascribe_forms/create_editions_form'; import ListRequestActions from '../ascribe_forms/list_form_request_actions'; -import GlobalNotificationModel from '../../models/global_notification_model'; -import GlobalNotificationActions from '../../actions/global_notification_actions'; - -import Note from './note'; +import AclProxy from '../acl_proxy'; import ApiUrls from '../../constants/api_urls'; import AscribeSpinner from '../ascribe_spinner'; @@ -54,6 +50,13 @@ import { setDocumentTitle } from '../../utils/dom_utils'; let PieceContainer = React.createClass({ propTypes: { furtherDetailsType: React.PropTypes.func, + + // Provided from AscribeApp + currentUser: React.PropTypes.object.isRequired, + whitelabel: React.PropTypes.object, + + // Provided from router + location: React.PropTypes.object, params: React.PropTypes.object }, @@ -67,7 +70,6 @@ let PieceContainer = React.createClass({ getInitialState() { return mergeOptions( - UserStore.getState(), PieceListStore.getState(), PieceStore.getInitialState(), { @@ -77,12 +79,10 @@ let PieceContainer = React.createClass({ }, componentDidMount() { - UserStore.listen(this.onChange); PieceListStore.listen(this.onChange); PieceStore.listen(this.onChange); this.loadPiece(); - UserActions.fetchCurrentUser(); }, // This is done to update the container when the user clicks on the prev or next @@ -105,7 +105,6 @@ let PieceContainer = React.createClass({ componentWillUnmount() { PieceStore.unlisten(this.onChange); - UserStore.unlisten(this.onChange); PieceListStore.unlisten(this.onChange); }, @@ -207,15 +206,17 @@ let PieceContainer = React.createClass({ }, getActions() { - const { piece, currentUser } = this.state; + const { piece } = this.state; + const { currentUser } = this.props; if (piece.notifications && piece.notifications.length > 0) { return ( ); + notifications={piece.notifications} + pieceOrEditions={piece} /> + ); } else { return ( ); } else if (action === 'acl_loan_request') { @@ -122,7 +123,7 @@ let AclFormFactory = React.createClass({ message={formMessage} id={this.getFormDataId()} url={this.isPiece() ? ApiUrls.ownership_shares_pieces - : ApiUrls.ownership_shares_editions} + : ApiUrls.ownership_shares_editions} handleSuccess={showNotification ? this.showSuccessNotification : handleSuccess} /> ); } else { diff --git a/js/components/ascribe_forms/form.js b/js/components/ascribe_forms/form.js index dc870d5a..fcc2f799 100644 --- a/js/components/ascribe_forms/form.js +++ b/js/components/ascribe_forms/form.js @@ -156,7 +156,7 @@ let Form = React.createClass({ for(let ref in this.refs) { if(this.refs[ref] && typeof this.refs[ref].handleSuccess === 'function'){ - this.refs[ref].handleSuccess(); + this.refs[ref].handleSuccess(response); } } this.setState({ diff --git a/js/components/ascribe_forms/form_consign.js b/js/components/ascribe_forms/form_consign.js index a28d2cff..e2e83567 100644 --- a/js/components/ascribe_forms/form_consign.js +++ b/js/components/ascribe_forms/form_consign.js @@ -123,8 +123,7 @@ let ConsignForm = React.createClass({ + className="ascribe-property-collapsible-toggle"> diff --git a/js/components/ascribe_forms/form_copyright_association.js b/js/components/ascribe_forms/form_copyright_association.js index c378ddba..f9b68f48 100644 --- a/js/components/ascribe_forms/form_copyright_association.js +++ b/js/components/ascribe_forms/form_copyright_association.js @@ -15,30 +15,30 @@ import { getLangText } from '../../utils/lang_utils'; let CopyrightAssociationForm = React.createClass({ propTypes: { - currentUser: React.PropTypes.object + currentUser: React.PropTypes.object.isRequired }, - handleSubmitSuccess(){ - let notification = getLangText('Copyright association updated'); - notification = new GlobalNotificationModel(notification, 'success', 10000); + handleSubmitSuccess() { + const notification = new GlobalNotificationModel(getLangText('Copyright association updated'), 'success', 10000); GlobalNotificationActions.appendGlobalNotification(notification); }, - getProfileFormData(){ - return {email: this.props.currentUser.email}; + getProfileFormData() { + return { email: this.props.currentUser.email }; }, render() { - let selectedState; - let selectDefaultValue = ' -- ' + getLangText('select an association') + ' -- '; + const { currentUser } = this.props; + const selectDefaultValue = ' -- ' + getLangText('select an association') + ' -- '; - if (this.props.currentUser && this.props.currentUser.profile - && this.props.currentUser.profile.copyright_association) { - selectedState = AppConstants.copyrightAssociations.indexOf(this.props.currentUser.profile.copyright_association); - selectedState = selectedState !== -1 ? AppConstants.copyrightAssociations[selectedState] : selectDefaultValue; + let selectedState = selectDefaultValue; + if (currentUser.profile && currentUser.profile.copyright_association) { + if (AppConstants.copyrightAssociations.indexOf(currentUser.profile.copyright_association) !== -1) { + selectedState = AppConstants.copyrightAssociations[selectedState]; + } } - if (this.props.currentUser && this.props.currentUser.email){ + if (currentUser.email) { return (
+ label={getLangText('Copyright Association')}> @@ -210,8 +210,7 @@ let LoanForm = React.createClass({ + className="ascribe-property-collapsible-toggle"> diff --git a/js/components/ascribe_forms/form_login.js b/js/components/ascribe_forms/form_login.js index a604850d..67069cac 100644 --- a/js/components/ascribe_forms/form_login.js +++ b/js/components/ascribe_forms/form_login.js @@ -6,7 +6,6 @@ import { History } from 'react-router'; import GlobalNotificationModel from '../../models/global_notification_model'; import GlobalNotificationActions from '../../actions/global_notification_actions'; -import UserStore from '../../stores/user_store'; import UserActions from '../../actions/user_actions'; import Form from './form'; @@ -23,8 +22,6 @@ let LoginForm = React.createClass({ propTypes: { headerMessage: React.PropTypes.string, submitMessage: React.PropTypes.string, - redirectOnLoggedIn: React.PropTypes.bool, - redirectOnLoginSuccess: React.PropTypes.bool, location: React.PropTypes.object }, @@ -32,40 +29,26 @@ let LoginForm = React.createClass({ getDefaultProps() { return { - headerMessage: getLangText('Enter ascribe'), - submitMessage: getLangText('Log in'), - redirectOnLoggedIn: true, - redirectOnLoginSuccess: true + headerMessage: getLangText('Enter') + ' ascribe', + submitMessage: getLangText('Log in') }; }, - getInitialState() { - return UserStore.getState(); - }, - - componentDidMount() { - UserStore.listen(this.onChange); - }, - - componentWillUnmount() { - UserStore.unlisten(this.onChange); - }, - - onChange(state) { - this.setState(state); - }, - - handleSuccess({ success }){ - let notification = new GlobalNotificationModel('Login successful', 'success', 10000); + handleSuccess({ success }) { + const notification = new GlobalNotificationModel(getLangText('Login successful'), 'success', 10000); GlobalNotificationActions.appendGlobalNotification(notification); - if(success) { + if (success) { UserActions.fetchCurrentUser(true); } }, render() { - let email = this.props.location.query.email || null; + const { + headerMessage, + location: { query: { email: emailQuery } }, + submitMessage } = this.props; + return ( - {this.props.submitMessage} + {submitMessage} } spinner={ @@ -85,7 +68,7 @@ let LoginForm = React.createClass({ }>
-

{this.props.headerMessage}

+

{headerMessage}

+ handleChangedFile={this.handleChangedDigitalWork} + showErrorPrompt /> + expanded={false}> {getLangText('Appendix')} {/* We're using disabled on a form here as PropertyCollapsible currently does not support the disabled + overrideForm functionality */} diff --git a/js/components/ascribe_forms/form_signup.js b/js/components/ascribe_forms/form_signup.js index 22f3e120..69fadd36 100644 --- a/js/components/ascribe_forms/form_signup.js +++ b/js/components/ascribe_forms/form_signup.js @@ -3,12 +3,11 @@ import React from 'react'; import { History } from 'react-router'; -import UserStore from '../../stores/user_store'; -import UserActions from '../../actions/user_actions'; - import GlobalNotificationModel from '../../models/global_notification_model'; import GlobalNotificationActions from '../../actions/global_notification_actions'; +import UserActions from '../../actions/user_actions'; + import Form from './form'; import Property from './property'; import InputCheckbox from './input_checkbox'; @@ -24,8 +23,12 @@ let SignupForm = React.createClass({ headerMessage: React.PropTypes.string, submitMessage: React.PropTypes.string, handleSuccess: React.PropTypes.func, - children: React.PropTypes.element, - location: React.PropTypes.object + location: React.PropTypes.object, + children: React.PropTypes.oneOfType([ + React.PropTypes.arrayOf(React.PropTypes.element), + React.PropTypes.element, + React.PropTypes.string + ]) }, mixins: [History], @@ -37,27 +40,11 @@ let SignupForm = React.createClass({ }; }, - getInitialState() { - return UserStore.getState(); - }, - - componentDidMount() { - UserStore.listen(this.onChange); - }, - - componentWillUnmount() { - UserStore.unlisten(this.onChange); - }, - - onChange(state) { - this.setState(state); - }, - handleSuccess(response) { if (response.user) { - let notification = new GlobalNotificationModel(getLangText('Sign up successful'), 'success', 50000); + const notification = new GlobalNotificationModel(getLangText('Sign up successful'), 'success', 50000); GlobalNotificationActions.appendGlobalNotification(notification); - + // Refactor this to its own component this.props.handleSuccess(getLangText('We sent an email to your address') + ' ' + response.user.email + ', ' + getLangText('please confirm') + '.'); } else { @@ -66,18 +53,20 @@ let SignupForm = React.createClass({ }, getFormData() { - if (this.props.location.query.token){ - return {token: this.props.location.query.token}; - } - return null; + const { token } = this.props.location.query; + return token ? { token } : null; }, render() { - let tooltipPassword = getLangText('Your password must be at least 10 characters') + '.\n ' + - getLangText('This password is securing your digital property like a bank account') + '.\n ' + - getLangText('Store it in a safe place') + '!'; + const { + children, + headerMessage, + location: { query: { email: emailQuery } }, + submitMessage } = this.props; - let email = this.props.location.query.email || null; + const tooltipPassword = getLangText('Your password must be at least 10 characters') + '.\n ' + + getLangText('This password is securing your digital property like a bank account') + '.\n ' + + getLangText('Store it in a safe place') + '!'; return ( - {this.props.submitMessage} - } + {submitMessage} + + } spinner={ - }> + }>
-

{this.props.headerMessage}

+

{headerMessage}

- {this.props.children} + {children} + className="ascribe-property-collapsible-toggle"> {' ' + getLangText('I agree to the Terms of Service of ascribe') + ' '} diff --git a/js/components/ascribe_forms/form_submit_to_prize.js b/js/components/ascribe_forms/form_submit_to_prize.js index 5818a9ce..43f28e2a 100644 --- a/js/components/ascribe_forms/form_submit_to_prize.js +++ b/js/components/ascribe_forms/form_submit_to_prize.js @@ -66,8 +66,7 @@ let PieceSubmitToPrizeForm = React.createClass({ + className="ascribe-property-collapsible-toggle"> {' ' + getLangText('I agree to the Terms of Service the art price') + ' '} diff --git a/js/components/ascribe_forms/form_transfer.js b/js/components/ascribe_forms/form_transfer.js index 956d766c..245de177 100644 --- a/js/components/ascribe_forms/form_transfer.js +++ b/js/components/ascribe_forms/form_transfer.js @@ -18,26 +18,26 @@ import { getLangText } from '../../utils/lang_utils.js'; let TransferForm = React.createClass({ propTypes: { - url: React.PropTypes.string, - id: React.PropTypes.object, - message: React.PropTypes.string, - editions: React.PropTypes.array, - currentUser: React.PropTypes.object, - handleSuccess: React.PropTypes.func + id: React.PropTypes.object.isRequired, + url: React.PropTypes.string.isRequired, + + handleSuccess: React.PropTypes.func, + message: React.PropTypes.string }, - getFormData(){ + getFormData() { return this.props.id; }, render() { + const { handleSuccess, message, url } = this.props; return (

@@ -70,7 +70,7 @@ let TransferForm = React.createClass({ overrideForm={true}> diff --git a/js/components/ascribe_forms/input_contract_agreement_checkbox.js b/js/components/ascribe_forms/input_contract_agreement_checkbox.js index 61235631..7008fe31 100644 --- a/js/components/ascribe_forms/input_contract_agreement_checkbox.js +++ b/js/components/ascribe_forms/input_contract_agreement_checkbox.js @@ -156,7 +156,7 @@ const InputContractAgreementCheckbox = React.createClass({ return (

+ style={{paddingBottom: '0.25em'}}> + handleChangedFile={handleChangedFile} /> ); } }); diff --git a/js/components/ascribe_forms/list_form_request_actions.js b/js/components/ascribe_forms/list_form_request_actions.js index 4ec01b46..3aa61359 100644 --- a/js/components/ascribe_forms/list_form_request_actions.js +++ b/js/components/ascribe_forms/list_form_request_actions.js @@ -4,32 +4,35 @@ import React from 'react'; import RequestActionForm from './form_request_action'; let ListRequestActions = React.createClass({ - propTypes: { + notifications: React.PropTypes.array.isRequired, pieceOrEditions: React.PropTypes.oneOfType([ React.PropTypes.object, React.PropTypes.array ]).isRequired, + currentUser: React.PropTypes.object, - handleSuccess: React.PropTypes.func.isRequired, - notifications: React.PropTypes.array.isRequired + handleSuccess: React.PropTypes.func }, render () { - if (this.props.notifications && - this.props.notifications.length > 0) { + const { currentUser, handleSuccess, notifications, pieceOrEditions } = this.props; + + if (notifications.length) { return (
- {this.props.notifications.map((notification) => + {notifications.map((notification) => )} + pieceOrEditions={pieceOrEditions} /> + )}
); + } else { + return null; } - return null; } }); diff --git a/js/components/ascribe_forms/property.js b/js/components/ascribe_forms/property.js index 6d46a9d3..b8b90400 100644 --- a/js/components/ascribe_forms/property.js +++ b/js/components/ascribe_forms/property.js @@ -72,7 +72,8 @@ const Property = React.createClass({ initialValue: null, value: null, isFocused: false, - errors: null + errors: null, + hasWarning: false }; }, @@ -218,17 +219,20 @@ const Property = React.createClass({ this.setState({errors: null}); }, + setWarning(hasWarning) { + this.setState({ hasWarning }); + }, + getClassName() { - if(!this.state.expanded && !this.props.checkboxLabel){ + if (!this.state.expanded && !this.props.checkboxLabel) { return 'is-hidden'; - } - if(!this.props.editable){ + } else if (!this.props.editable) { return 'is-fixed'; - } - if (this.state.errors){ + } else if (this.state.errors) { return 'is-error'; - } - if(this.state.isFocused) { + } else if (this.state.hasWarning) { + return 'is-warning'; + } else if (this.state.isFocused) { return 'is-focused'; } else { return ''; @@ -271,6 +275,7 @@ const Property = React.createClass({ onChange: this.handleChange, onFocus: this.handleFocus, onBlur: this.handleBlur, + setWarning: this.setWarning, disabled: !this.props.editable, ref: 'input', name: this.props.name, @@ -294,18 +299,18 @@ const Property = React.createClass({ }, getCheckbox() { - const { checkboxLabel } = this.props; + const { checkboxLabel, name } = this.props; - if(checkboxLabel) { + if (checkboxLabel) { return (
+ onChange={this.handleCheckboxToggle} + type="checkbox" /> {' ' + checkboxLabel}
); diff --git a/js/components/ascribe_modal/modal_wrapper.js b/js/components/ascribe_modal/modal_wrapper.js index 511e7f8c..53f1c90b 100644 --- a/js/components/ascribe_modal/modal_wrapper.js +++ b/js/components/ascribe_modal/modal_wrapper.js @@ -6,6 +6,10 @@ import Modal from 'react-bootstrap/lib/Modal'; let ModalWrapper = React.createClass({ propTypes: { + children: React.PropTypes.oneOfType([ + React.PropTypes.arrayOf(React.PropTypes.element), + React.PropTypes.element + ]).isRequired, title: React.PropTypes.oneOfType([ React.PropTypes.arrayOf(React.PropTypes.element), React.PropTypes.element, @@ -14,11 +18,7 @@ let ModalWrapper = React.createClass({ handleCancel: React.PropTypes.func, handleSuccess: React.PropTypes.func, - trigger: React.PropTypes.element, - children: React.PropTypes.oneOfType([ - React.PropTypes.arrayOf(React.PropTypes.element), - React.PropTypes.element - ]) + trigger: React.PropTypes.element }, getInitialState() { diff --git a/js/components/ascribe_piece_list_toolbar/piece_list_toolbar_filter_widget.js b/js/components/ascribe_piece_list_toolbar/piece_list_toolbar_filter_widget.js index 07e9bc5c..68a23fa7 100644 --- a/js/components/ascribe_piece_list_toolbar/piece_list_toolbar_filter_widget.js +++ b/js/components/ascribe_piece_list_toolbar/piece_list_toolbar_filter_widget.js @@ -84,6 +84,7 @@ let PieceListToolbarFilterWidget = React.createClass({ if (this.props.filterParams && this.props.filterParams.length) { return ( diff --git a/js/components/ascribe_piece_list_toolbar/piece_list_toolbar_order_widget.js b/js/components/ascribe_piece_list_toolbar/piece_list_toolbar_order_widget.js index 26226410..d689c7fe 100644 --- a/js/components/ascribe_piece_list_toolbar/piece_list_toolbar_order_widget.js +++ b/js/components/ascribe_piece_list_toolbar/piece_list_toolbar_order_widget.js @@ -55,6 +55,7 @@ let PieceListToolbarOrderWidget = React.createClass({ if (this.props.orderParams && this.props.orderParams.length) { return ( diff --git a/js/components/ascribe_routes/proxy_handler.js b/js/components/ascribe_routes/proxy_handler.js index 7752912a..882fe65c 100644 --- a/js/components/ascribe_routes/proxy_handler.js +++ b/js/components/ascribe_routes/proxy_handler.js @@ -5,7 +5,6 @@ import { RouteContext } from 'react-router'; import history from '../../history'; import UserStore from '../../stores/user_store'; -import UserActions from '../../actions/user_actions'; import AppConstants from '../../constants/application_constants'; @@ -18,11 +17,11 @@ const WHEN_ENUM = ['loggedIn', 'loggedOut']; * * @param {enum/string} options.when ('loggedIn' || 'loggedOut') */ -export function AuthRedirect({to, when}) { +export function AuthRedirect({ to, when }) { // validate `when`, must be contained in `WHEN_ENUM`. // Throw an error otherwise. - if(WHEN_ENUM.indexOf(when) === -1) { - let whenValues = WHEN_ENUM.join(', '); + if (WHEN_ENUM.indexOf(when) === -1) { + const whenValues = WHEN_ENUM.join(', '); throw new Error(`"when" must be one of: [${whenValues}] got "${when}" instead`); } @@ -35,23 +34,22 @@ export function AuthRedirect({to, when}) { // // So if when === 'loggedIn', we're checking if the user is logged in (and // vice versa) - let exprToValidate = when === 'loggedIn' ? currentUser && currentUser.email - : currentUser && !currentUser.email; + const isLoggedIn = Object.keys(currentUser).length && currentUser.email; + const exprToValidate = when === 'loggedIn' ? isLoggedIn : !isLoggedIn; // and redirect if `true`. - if(exprToValidate) { + if (exprToValidate) { window.setTimeout(() => history.replace({ query, pathname: to })); return true; // Otherwise there can also be the case that the backend // wants to redirect the user to a specific route when the user is logged out already - } else if(!exprToValidate && when === 'loggedIn' && redirect) { - + } else if (!exprToValidate && when === 'loggedIn' && redirect) { delete query.redirect; window.setTimeout(() => history.replace({ query, pathname: '/' + redirect })); return true; - } else if(!exprToValidate && when === 'loggedOut' && redirectAuthenticated) { + } else if (!exprToValidate && when === 'loggedOut' && redirectAuthenticated) { /* * redirectAuthenticated contains an arbitrary path * eg pieces/, editions/, collection, settings, ... @@ -64,6 +62,7 @@ export function AuthRedirect({to, when}) { window.location = AppConstants.baseUrl + redirectAuthenticated; return true; } + return false; }; } @@ -81,6 +80,11 @@ export function ProxyHandler(...redirectFunctions) { displayName: 'ProxyHandler', propTypes: { + // Provided from AscribeApp, after the routes have been initialized + currentUser: React.PropTypes.object, + whitelabel: React.PropTypes.object, + + // Provided from router location: object }, @@ -88,43 +92,33 @@ export function ProxyHandler(...redirectFunctions) { // to use the `Lifecycle` widget in further down nested components mixins: [RouteContext], - getInitialState() { - return UserStore.getState(); - }, - componentDidMount() { - UserStore.listen(this.onChange); - UserActions.fetchCurrentUser(); + this.evaluateRedirectFunctions(); }, - componentDidUpdate() { - if(!UserStore.isLoading()) { - const { currentUser } = this.state; - const { query } = this.props.location; + componentWillReceiveProps(nextProps) { + this.evaluateRedirectFunctions(nextProps); + }, - for(let i = 0; i < redirectFunctions.length; i++) { + evaluateRedirectFunctions(props = this.props) { + const { currentUser, location: { query } } = props; + + if (UserStore.hasLoaded() && !UserStore.isLoading()) { + for (let i = 0; i < redirectFunctions.length; i++) { // if a redirectFunction redirects the user, // it should return `true` and therefore // stop/avoid the execution of all functions // that follow - if(redirectFunctions[i](currentUser, query)) { + if (redirectFunctions[i](currentUser, query)) { break; } } } }, - componentWillUnmount() { - UserStore.unlisten(this.onChange); - }, - - onChange(state) { - this.setState(state); - }, - render() { return ( - + ); } }); diff --git a/js/components/ascribe_settings/account_settings.js b/js/components/ascribe_settings/account_settings.js index c650358c..c8d4d64a 100644 --- a/js/components/ascribe_settings/account_settings.js +++ b/js/components/ascribe_settings/account_settings.js @@ -26,21 +26,23 @@ let AccountSettings = React.createClass({ whitelabel: React.PropTypes.object.isRequired }, - handleSuccess(){ + handleSuccess() { this.props.loadUser(true); - let notification = new GlobalNotificationModel(getLangText('Settings succesfully updated'), 'success', 5000); + + const notification = new GlobalNotificationModel(getLangText('Settings succesfully updated'), 'success', 5000); GlobalNotificationActions.appendGlobalNotification(notification); }, - getFormDataProfile(){ - return {'email': this.props.currentUser.email}; + getFormDataProfile() { + return { 'email': this.props.currentUser.email }; }, - + render() { - let content = ; + const { currentUser, whitelabel } = this.props; + let content = ; let profile = null; - if (this.props.currentUser.username) { + if (currentUser.username) { content = ( @@ -61,7 +63,7 @@ let AccountSettings = React.createClass({ editable={false}> @@ -70,7 +72,7 @@ let AccountSettings = React.createClass({ ); profile = ( + className="ascribe-property-collapsible-toggle"> + defaultChecked={currentUser.profile.hash_locally}> {' ' + getLangText('Enable hash option, e.g. slow connections or to keep piece private')} @@ -97,9 +98,9 @@ let AccountSettings = React.createClass({ defaultExpanded={true}> {content} - + {profile} diff --git a/js/components/ascribe_settings/contract_settings.js b/js/components/ascribe_settings/contract_settings.js index be723295..37305381 100644 --- a/js/components/ascribe_settings/contract_settings.js +++ b/js/components/ascribe_settings/contract_settings.js @@ -8,12 +8,6 @@ import CreateContractForm from '../ascribe_forms/form_create_contract'; import ContractListStore from '../../stores/contract_list_store'; import ContractListActions from '../../actions/contract_list_actions'; -import UserStore from '../../stores/user_store'; -import UserActions from '../../actions/user_actions'; - -import WhitelabelStore from '../../stores/whitelabel_store'; -import WhitelabelActions from '../../actions/whitelabel_actions'; - import ActionPanel from '../ascribe_panel/action_panel'; import ContractSettingsUpdateButton from './contract_settings_update_button'; @@ -24,30 +18,29 @@ import AclProxy from '../acl_proxy'; import { getLangText } from '../../utils/lang_utils'; import { setDocumentTitle } from '../../utils/dom_utils'; -import { mergeOptions, truncateTextAtCharIndex } from '../../utils/general_utils'; +import { truncateTextAtCharIndex } from '../../utils/general_utils'; let ContractSettings = React.createClass({ + propTypes: { + // Provided from AscribeApp + currentUser: React.PropTypes.object.isRequired, + whitelabel: React.PropTypes.object.isRequired, + + // Provided from router + location: React.PropTypes.object + }, + getInitialState() { - return mergeOptions( - ContractListStore.getState(), - UserStore.getState() - ); + return ContractListStore.getState(); }, componentDidMount() { ContractListStore.listen(this.onChange); - UserStore.listen(this.onChange); - WhitelabelStore.listen(this.onChange); - - WhitelabelActions.fetchWhitelabel(); - UserActions.fetchCurrentUser(); ContractListActions.fetchContractList(true); }, componentWillUnmount() { - WhitelabelStore.unlisten(this.onChange); - UserStore.unlisten(this.onChange); ContractListStore.unlisten(this.onChange); }, @@ -79,6 +72,7 @@ let ContractSettings = React.createClass({ }, render() { + const { currentUser, location, whitelabel } = this.props; const publicContracts = this.getPublicContracts(); const privateContracts = this.getPrivateContracts(); let createPublicContractForm = null; @@ -88,11 +82,11 @@ let ContractSettings = React.createClass({ if (publicContracts.length === 0) { createPublicContractForm = ( + }} + isPublic={true} /> ); } @@ -103,7 +97,7 @@ let ContractSettings = React.createClass({ defaultExpanded={true}> + aclObject={currentUser.acl}>
{createPublicContractForm} {publicContracts.map((contract, i) => { @@ -115,10 +109,9 @@ let ContractSettings = React.createClass({ buttons={
- + + aclObject={currentUser.acl}>
+ fileClassToUpload={{ + singular: getLangText('new contract'), + plural: getLangText('new contracts') + }} + isPublic={false} /> {privateContracts.map((contract, i) => { return ( - + - {this.props.children} + whitelabel={whitelabel} /> + {children} diff --git a/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop.js b/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop.js index 1157a540..37fc3f23 100644 --- a/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop.js +++ b/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop.js @@ -1,29 +1,33 @@ 'use strict'; import React from 'react'; +import classNames from 'classnames'; import ProgressBar from 'react-bootstrap/lib/ProgressBar'; import FileDragAndDropDialog from './file_drag_and_drop_dialog'; +import FileDragAndDropErrorDialog from './file_drag_and_drop_error_dialog'; import FileDragAndDropPreviewIterator from './file_drag_and_drop_preview_iterator'; +import { FileStatus } from '../react_s3_fine_uploader_utils'; import { getLangText } from '../../../utils/lang_utils'; // Taken from: https://github.com/fedosejev/react-file-drag-and-drop let FileDragAndDrop = React.createClass({ propTypes: { - className: React.PropTypes.string, + areAssetsDownloadable: React.PropTypes.bool, + areAssetsEditable: React.PropTypes.bool, + multiple: React.PropTypes.bool, + dropzoneInactive: React.PropTypes.bool, + filesToUpload: React.PropTypes.array, + onDrop: React.PropTypes.func.isRequired, onDragOver: React.PropTypes.func, - filesToUpload: React.PropTypes.array, handleDeleteFile: React.PropTypes.func, handleCancelFile: React.PropTypes.func, handlePauseFile: React.PropTypes.func, handleResumeFile: React.PropTypes.func, - multiple: React.PropTypes.bool, - dropzoneInactive: React.PropTypes.bool, - areAssetsDownloadable: React.PropTypes.bool, - areAssetsEditable: React.PropTypes.bool, + handleRetryFiles: React.PropTypes.func, enableLocalHashing: React.PropTypes.bool, uploadMethod: React.PropTypes.string, @@ -34,6 +38,12 @@ let FileDragAndDrop = React.createClass({ // to -1 which is code for: aborted handleCancelHashing: React.PropTypes.func, + showError: React.PropTypes.bool, + errorClass: React.PropTypes.shape({ + name: React.PropTypes.string, + prettifiedText: React.PropTypes.string + }), + // A class of a file the user has to upload // Needs to be defined both in singular as well as in plural fileClassToUpload: React.PropTypes.shape({ @@ -126,65 +136,93 @@ let FileDragAndDrop = React.createClass({ } }, + getErrorDialog(failedFiles) { + const { errorClass } = this.props; + + return ( + + ); + }, + + getPreviewIterator() { + const { areAssetsDownloadable, areAssetsEditable, filesToUpload } = this.props; + + return ( + + ); + }, + + getUploadDialog() { + const { enableLocalHashing, fileClassToUpload, multiple, uploadMethod } = this.props; + + return ( + + ); + }, + render: function () { const { filesToUpload, dropzoneInactive, - className, hashingProgress, handleCancelHashing, multiple, - enableLocalHashing, - uploadMethod, + showError, + errorClass, fileClassToUpload, - areAssetsDownloadable, - areAssetsEditable, allowedExtensions } = this.props; - // has files only is true if there are files that do not have the status deleted or canceled - let hasFiles = filesToUpload.filter((file) => file.status !== 'deleted' && file.status !== 'canceled' && file.size !== -1).length > 0; - let updatedClassName = hasFiles ? 'has-files ' : ''; - updatedClassName += dropzoneInactive ? 'inactive-dropzone' : 'active-dropzone'; - updatedClassName += ' file-drag-and-drop'; + // has files only is true if there are files that do not have the status deleted, canceled, or failed + const hasFiles = filesToUpload + .filter((file) => { + return file.status !== FileStatus.DELETED && + file.status !== FileStatus.CANCELED && + file.status !== FileStatus.UPLOAD_FAILED && + file.size !== -1; + }) + .length > 0; + + const failedFiles = filesToUpload.filter((file) => file.status === FileStatus.UPLOAD_FAILED); + let hasError = showError && errorClass && failedFiles.length > 0; // if !== -2: triggers a FileDragAndDrop-global spinner - if(hashingProgress !== -2) { + if (hashingProgress !== -2) { return ( -
- +
+

{getLangText('Computing hash(es)... This may take a few minutes.')}

+

+ {getLangText('Cancel hashing')} +

+
); } else { return (
- - + {hasError ? this.getErrorDialog(failedFiles) : this.getPreviewIterator()} + {!hasFiles && !hasError ? this.getUploadDialog() : null} {/* Opera doesn't trigger simulated click events if the targeted input has `display:none` set. diff --git a/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_dialog.js b/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_dialog.js index db28846b..5df3558c 100644 --- a/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_dialog.js +++ b/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_dialog.js @@ -9,7 +9,6 @@ import { getCurrentQueryParams } from '../../../utils/url_utils'; let FileDragAndDropDialog = React.createClass({ propTypes: { - hasFiles: React.PropTypes.bool, multipleFiles: React.PropTypes.bool, enableLocalHashing: React.PropTypes.bool, uploadMethod: React.PropTypes.string, @@ -36,92 +35,88 @@ let FileDragAndDropDialog = React.createClass({ render() { const { - hasFiles, multipleFiles, enableLocalHashing, uploadMethod, fileClassToUpload, onClick } = this.props; + let dialogElement; - if (hasFiles) { - return null; - } else { - let dialogElement; + if (enableLocalHashing && !uploadMethod) { + const currentQueryParams = getCurrentQueryParams(); - if (enableLocalHashing && !uploadMethod) { - const currentQueryParams = getCurrentQueryParams(); + const queryParamsHash = Object.assign({}, currentQueryParams); + queryParamsHash.method = 'hash'; - const queryParamsHash = Object.assign({}, currentQueryParams); - queryParamsHash.method = 'hash'; + const queryParamsUpload = Object.assign({}, currentQueryParams); + queryParamsUpload.method = 'upload'; - const queryParamsUpload = Object.assign({}, currentQueryParams); - queryParamsUpload.method = 'upload'; - - dialogElement = ( -
-

{getLangText('Would you rather')}

- {/* - The frontend in live is hosted under /app, - Since `Link` is appending that base url, if its defined - by itself, we need to make sure to not set it at this point. - Otherwise it will be appended twice. - */} - - - {getLangText('Hash your work')} - - - - or - - - - {getLangText('Upload and hash your work')} - - -
- ); - } else { - if (multipleFiles) { - dialogElement = [ - this.getDragDialog(fileClassToUpload.plural), - - {getLangText('choose %s to upload', fileClassToUpload.plural)} + dialogElement = ( +
+

{getLangText('Would you rather')}

+ {/* + The frontend in live is hosted under /app, + Since `Link` is appending that base url, if its defined + by itself, we need to make sure to not set it at this point. + Otherwise it will be appended twice. + */} + + + {getLangText('Hash your work')} - ]; - } else { - const dialog = uploadMethod === 'hash' ? getLangText('choose a %s to hash', fileClassToUpload.singular) - : getLangText('choose a %s to upload', fileClassToUpload.singular); + - dialogElement = [ - this.getDragDialog(fileClassToUpload.singular), - - {dialog} + {getLangText('or')} + + + + {getLangText('Upload and hash your work')} - ]; - } - } - - return ( -
-
- {dialogElement} -
- {/* Hide the uploader and just show that there's been on files uploaded yet when printing */} -

- {getLangText('No files uploaded')} -

+
); + } else { + if (multipleFiles) { + dialogElement = [ + this.getDragDialog(fileClassToUpload.plural), + ( + {getLangText('choose %s to upload', fileClassToUpload.plural)} + ) + ]; + } else { + const dialog = uploadMethod === 'hash' ? getLangText('choose a %s to hash', fileClassToUpload.singular) + : getLangText('choose a %s to upload', fileClassToUpload.singular); + + dialogElement = [ + this.getDragDialog(fileClassToUpload.singular), + ( + {dialog} + ) + ]; + } } + + return ( +
+
+ {dialogElement} +
+ {/* Hide the uploader and just show that there's been on files uploaded yet when printing */} +

+ {getLangText('No files uploaded')} +

+
+ ); } }); diff --git a/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_error_dialog.js b/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_error_dialog.js new file mode 100644 index 00000000..a88d2e35 --- /dev/null +++ b/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_error_dialog.js @@ -0,0 +1,86 @@ +'use strict'; + +import React from 'react'; +import classNames from 'classnames'; + +import { ErrorClasses } from '../../../constants/error_constants'; + +import { getLangText } from '../../../utils/lang_utils'; + +let FileDragAndDropErrorDialog = React.createClass({ + propTypes: { + errorClass: React.PropTypes.shape({ + name: React.PropTypes.string, + prettifiedText: React.PropTypes.string + }).isRequired, + files: React.PropTypes.array.isRequired, + handleRetryFiles: React.PropTypes.func.isRequired + }, + + getRetryButton(text, openIntercom) { + return ( + + ); + }, + + getContactUsDetail() { + return ( +
+

{getLangText('Let us help you')}

+

{getLangText('Still having problems? Send us a message.')}

+ {this.getRetryButton('Contact us', true)} +
+ ); + }, + + getErrorDetail(multipleFiles) { + const { errorClass: { prettifiedText }, files } = this.props; + + return ( +
+
+

{getLangText(multipleFiles ? 'Some files did not upload correctly' + : 'Error uploading the file!')} +

+

{prettifiedText}

+ {this.getRetryButton('Retry')} +
+ + + +
+
    + {files.map((file) => (
  • {file.originalName}
  • ))} +
+
+
+ ); + }, + + retryAllFiles() { + const { files, handleRetryFiles } = this.props; + handleRetryFiles(files.map(file => file.id)); + }, + + render() { + const { errorClass: { name: errorName }, files } = this.props; + + const multipleFiles = files.length > 1; + const contactUs = errorName === ErrorClasses.upload.contactUs.name; + + return contactUs ? this.getContactUsDetail() : this.getErrorDetail(multipleFiles); + } +}); + +export default FileDragAndDropErrorDialog; diff --git a/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_preview.js b/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_preview.js index ca1be2d2..0c326ed4 100644 --- a/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_preview.js +++ b/js/components/ascribe_uploader/ascribe_file_drag_and_drop/file_drag_and_drop_preview.js @@ -5,6 +5,7 @@ import React from 'react'; import FileDragAndDropPreviewImage from './file_drag_and_drop_preview_image'; import FileDragAndDropPreviewOther from './file_drag_and_drop_preview_other'; +import { FileStatus } from '../react_s3_fine_uploader_utils'; import { getLangText } from '../../../utils/lang_utils'; import { truncateTextAtCharIndex } from '../../../utils/general_utils'; import { extractFileExtensionFromString } from '../../../utils/file_utils'; @@ -23,27 +24,30 @@ const FileDragAndDropPreview = React.createClass({ s3Url: string, s3UrlSafe: string }).isRequired, + + areAssetsDownloadable: bool, + areAssetsEditable: bool, handleDeleteFile: func, handleCancelFile: func, handlePauseFile: func, handleResumeFile: func, - areAssetsDownloadable: bool, - areAssetsEditable: bool, numberOfDisplayedFiles: number }, toggleUploadProcess() { - if(this.props.file.status === 'uploading') { - this.props.handlePauseFile(this.props.file.id); - } else if(this.props.file.status === 'paused') { - this.props.handleResumeFile(this.props.file.id); + const { file, handlePauseFile, handleResumeFile } = this.props; + + if (file.status === FileStatus.UPLOADING) { + handlePauseFile(file.id); + } else if (file.status === FileStatus.PAUSED) { + handleResumeFile(file.id); } }, handleDeleteFile() { - const { handleDeleteFile, - handleCancelFile, - file } = this.props; + const { file, + handleDeleteFile, + handleCancelFile } = this.props; // `handleDeleteFile` is optional, so if its not submitted, don't run it // // For delete though, we only want to trigger it, when we're sure that @@ -51,16 +55,16 @@ const FileDragAndDropPreview = React.createClass({ // deleted using an HTTP DELETE request. if (handleDeleteFile && file.progress === 100 && - (file.status === 'upload successful' || file.status === 'online') && + (file.status === FileStatus.UPLOAD_SUCCESSFUL || file.status === FileStatus.ONLINE) && file.s3UrlSafe) { handleDeleteFile(file.id); - } else if(handleCancelFile) { + } 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 +73,7 @@ const FileDragAndDropPreview = React.createClass({ getFileName() { const { numberOfDisplayedFiles, file } = this.props; - if(numberOfDisplayedFiles === 1) { + if (numberOfDisplayedFiles === 1) { return ( {truncateTextAtCharIndex(file.name, 30, '(...).' + extractFileExtensionFromString(file.name))} @@ -81,7 +85,7 @@ const FileDragAndDropPreview = React.createClass({ }, getRemoveButton() { - if(this.props.areAssetsEditable) { + if (this.props.areAssetsEditable) { return (